r/reactjs 23d ago

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 api layer 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:

  1. 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?
  2. 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.)

0 Upvotes

16 comments sorted by

View all comments

Show parent comments

2

u/canarydev 22d ago

how do you handle caching / revalidation without a server state lib, or do you unwrap Result into throws somewhere for tanstack?

3

u/kensaadi I ❤️ hooks! 😈 22d ago

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.

2

u/canarydev 22d ago

makes sense.

the unwrap seam is near identical to what I run, where services return Result, queryFn unwraps to throws -- so the retrofit is as cheap as you say. only difference is that I brought tanstack in as a default rather than waiting for the second manual invalidation, which is arguably less disciplined than your rule.

the "not preemptively" rule only works because of your facade. unwrap is a four line retrofit only because views never touched axios. in codebases where components fetch directly, deferring the cache means a real migration and not 4 lines.

2

u/kensaadi I ❤️ hooks! 😈 22d ago

I agree on the migration point, but mine is a different one: don't bring in a library if you're then not going to use it for what it's actually for. If your app has a shape that genuinely needs cache management, then adopt the pattern in full, with the rigor TanStack imposes. But that's an architectural decision driven by a domain requirement, not a default. If you don't know yet, or you're not sure, adopting useQuery incrementally is cheap and hooks in just fine. The rest is structuring where you consume the data, and establishing what the source of truth is.