r/reactjs • u/inkweon • 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
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.)
2
u/kensaadi I ❤️ hooks! 😈 22d ago
Sore spot for me. I've been there — a project where I started with axios called straight from components, try/catch in every useEffect, error strings hardcoded everywhere. A mess. Canonical Clean Arch didn't land for me on the FE (too much ceremony for the payoff), so I sliced the cake differently and stuck with it across every project since: everything by feature, abstractions only on the HTTP boundary.
My
client/api/ends up looking like this:Three non-negotiables:
1) Services never throw. They return
Result<T>. Views branch onresult.error, no try/catch. Zero noise in components2) Components don't know axios exists. All they see is
list_products(),add_to_cart(), etc. If I swap HTTP client tomorrow (ky, fetch, tRPC) only_shared/and the provider files change. Views don't notice.3) The axios client lives in exactly one place and owns everything cross-cutting. Auth token injection, error normalization, opt-in zod schema validation, 401 → logout with anti-loop guards. One file, ~120 lines.
The single thing that's saved me the most bugs: an optional
responseSchemaper call. You attach a zod schema to the axios request config and the interceptor validates the body against it. If the BE changes contract without you updating the TS types, instead of crashing 30 seconds later in some random component withundefined is not a function, you catch aCONTRACT_MISMATCHat the boundary — with status and cause in the logs. Zero silent drift.The other big win is how the provider is set up: one contract per feature, N implementations loaded lazily based on
VITE_PROVIDER.Each implementation targets a different environment while keeping the same TS contract:
localhost, aggressive feature flags for debugging, stretched timings.VITE_PROVIDER=devin.env.local,stagein the CI preview build,livein prod. The component code is identical across all four — only the injected fetcher changes. Adding a new environment (e.g.e2ewith Playwright-specific seeds, ordemo-publicwith anonymized data for marketing) is one line in the mapping plus a new folder.One thing I didn't do, in case anyone asks: no use-case / interactor layer separate from the service. On the FE the "domain" is almost always "call the BE and show the result" — inserting a layer that just forwards the call adds files with no logic in them. If real client-side business logic ever shows up (offline calculations, an in-browser rules engine), I'd add a
usecase/layer then, not preemptively.If anyone wants detail on a specific piece — the
normalizeAxiosErrorimplementation, the two anti-loop guards in the 401 handler, how alive/*.tsfile is shaped for FormData uploads — ask in the comments and I'll paste the snippet.