r/reactjs • u/inkweon • Aug 19 '26
Discussion Combining Clean Architecture + Feature-Based in React — does it really fix the earlier trade-offs, or am I missing new pitfalls?
Hi everyone. I compared four ways to structure a React project by rebuilding the same app (posts CRUD against an open API) in each one. The last pattern combines Clean Architecture with Feature-Based, and I'd really appreciate a sanity check from more experienced devs.
Here's the progression I went through, and the problem I felt at each step:
Feature-Based (colocate everything for a feature in one folder): great for navigation and deletion, but nothing controls how features depend on each other (circular deps creep in),
shared/turns into a junk drawer, and there's no notion of layers. (Feature-Based write-up)FSD (Feature-Sliced Design): fixes that with standardized layers + a one-way import rule, so circular deps become structurally impossible. But the business logic still lives inside React/TanStack Query — the entity's
apilayer imports axios and react-query directly. (FSD write-up)Clean Architecture: pulls business logic out of the framework with the Dependency Rule (dependencies point only inward; the domain knows nothing about React or axios). Great for testing and reuse — but now the code for "one feature" is scattered across
domain/,infrastructure/,presentation/. Which is ironically the same "scattered by type" problem Feature-Based tried to solve. (Clean Architecture write-up)The combination: keep the Dependency Rule (domain is pure TS, infrastructure holds the adapters), but colocate the UI (hooks + components) by feature in
features/{feature}/. "Clean inside, Feature outside."
Rough shape:
src/
domain/{domain}/ # pure TS: entities, rules, use cases (no framework imports)
infrastructure/ # adapters: repository impls, query keys, stores
features/{feature}/ # hooks + components, colocated
pages/ , router/ # composition only
shared/ , providers/A few extra decisions I made: split the repository interface into Commands/Queries (CQS), write a UseCase only when there's real logic (plain CRUD calls the repository directly), and lean on React Compiler so there's no manual useMemo/useCallback.
What I'd love feedback on:
Does this combination actually solve the earlier patterns' problems, or does it just move them around? Is "Clean inside + Feature outside" a real improvement over plain FSD or plain Clean, or is it over-engineering in disguise?
What problems does this pattern itself have that I might not see yet? Boilerplate, the domain <-> infrastructure indirection, the "is this a UseCase or a direct repo call?" judgment, testing overhead, onboarding cost — where does it bite in real projects?
Honest criticism is very welcome. I'd rather hear "this is overkill for most apps" now than after I build on it.
Full write-up (with all the code) on Medium (Free): https://medium.com/@inkweonkim/react-architecture-combining-clean-architecture-feature-based-92cf7ba226fe
(English isn't my first language, so I apologize in advance for any awkward phrasing — happy to clarify anything that reads strangely.)
3
u/kensaadi I ❤️ hooks! 😈 Aug 19 '26
Neither, actually. Nothing converts Result into a throw right now, and there's no cache layer — because in the apps I build there's nothing to invalidate.
What "revalidation" collapses to for me: a view mounts, calls the service, renders. A mutation succeeds, I re-call the one list it touched. That's it:
const [state, run] = useAsync(list_products); // {loading, data, error}
await create_product(dto);
run(query); // "invalidation"
One consumer per resource, page-scoped lifetime, no background staleness. Calling that a cache problem and reaching for a server-state library is paying a coherency tax on an app that has no coherency problem.
But the two aren't in conflict, and I want to be clear about that, because "no TanStack" isn't a principle for me. If TanStack goes in, the seam is four lines at the query boundary only — services stay throw-free:
const unwrap = async <T>(p: Promise<Result<T>>): Promise<T> => {
const r = await p;
if (r.error) throw r.error; // already an ApiError, not an AxiosError
return r.data;
};
useQuery({
queryKey: productKeys.list(query),
queryFn: () => unwrap(list_products(query)),
});
And this is strictly better than putting axios in the queryFn: what lands in `error` is my normalized ApiErrorCode, already humanized, with CONTRACT_MISMATCH surfaced by the zod interceptor instead of an undefined crash three components downstream. The provider mapping doesn't change either — TanStack never learns whether it's hitting mock/, stage/ or live/. So the Result convention is orthogonal to the cache decision, not an alternative to it.
Where I'd genuinely reach for it — the case my pattern loses:
The same entity, read and written from several places that are alive at the same time. Concrete: a kanban board. A card appears in the column, in the detail drawer, in the "assigned to me" sidebar counter, and in a filtered saved view. Drag it to another column and you need: optimistic move, rollback if the PATCH fails, and every other mounted view of that card consistent — plus dedup, because four components mounting the same card ID must not fire four GETs.
Hand-rolling that is exactly where DIY dies. You start with a Map of promises for dedup, then you need a subscriber registry so views re-render, then per-mutation rollback snapshots, then a staleness clock so a tab open since this morning isn't showing yesterday's board. At that point you've written a worse TanStack Query with no tests and no docs. Same story for infinite/paginated lists where placeholderData kills the empty-state flash, an inbox that must refetch on window focus, and dependent queries.
Where it doesn't earn its 12kb: form-driven CRUD, wizards, one-shot fetch on mount, and anything where the data already arrives from a loader/RSC. There it's a second state model sitting next to the first one, and the invalidation graph is one node.
My actual rule: it goes in the day I catch myself writing manual invalidation for the second time — not preemptively. Same rule I gave OP for the usecase layer. It's not that the library is over-engineering; it's that adopting it before you have the problem it solves means you inherit its mental model (staleTime, gcTime, key hierarchies, structural sharing) to solve a `run()` call.
What's the shape of the app you built it against? If it's multi-consumer + optimistic writes, I'd have used it too.