A million lines of React is not a stack problem. It’s a boundaries problem — here’s the line I use
Marcus Webb
September 21, 2026
Every few years someone declares that React does not scale. Then they point at a repository with a million lines, a six-second hot reload, and a folder named components that contains both a Button and a BillingOrchestrator. The diagnosis is usually “we picked the wrong stack.”
I have inherited those repos. The stack was rarely the villain. The villain was missing boundaries — no clear line between UI composition, domain rules, data fetching, and cross-cutting glue. React will happily render whatever graph of hooks you hand it. It will not invent module seams for you.
Here is the line I use when a frontend starts to feel like a distributed monolith in a monorepo hoodie.
What “a million lines of React” usually means
It rarely means a million lines of JSX. It means:
- Feature folders that import each other’s internals
- Shared “utils” that know about auth, money, and date math
- Hooks that fetch, transform, cache, and render policy in one file
- Design-system components that quietly became business workflows
- Tests that mount half the app to assert a label
Rewriting in Vue, Svelte, or “just server components everywhere” does not fix those shapes. It relocates them. I have watched teams burn a year on a framework migration and recreate the same ball of mud with newer syntax.

The boundary line I actually draw
I use a simple test for every module: what is allowed to know about what?
Four layers, with one-way dependencies:
- UI primitives — buttons, inputs, layout. No product nouns. No fetch.
- Feature UI — screens and flows for a product area. May compose primitives. May call feature APIs. Must not reach into another feature’s private components.
- Domain/client logic — pure-ish functions and types for money, entitlements, scheduling rules. No React imports if I can help it.
- Data access — API clients, query hooks, cache keys. Returns domain types. Does not decide button copy.
The line I defend hardest: feature A may depend on a public surface of feature B, never on B’s internal file tree. If you need something from Billing, Billing exports it. If Billing will not export it, that is a product conversation, not a relative-import conversation.
How I spot a boundary violation in review
I look for these smells more than I look for clever hook patterns:
../../other-feature/hooks/useSecretThing- A shared context that carries half the session state “for convenience”
- A design-system Modal that imports a pricing calculator
- Query keys invented ad hoc in twelve files
- Domain rules expressed only inside JSX conditionals
When I see those, I do not start with “rewrite.” I start with “name the seam.” Sometimes the fix is a ten-line public API in the owning feature. Sometimes it is extracting a domain function. Sometimes it is admitting two features are actually one and collapsing them on purpose.
Boundaries beat folder religions
I have used feature folders, hexagonal fantasies, and “pages vs widgets” schemes. The scheme matters less than whether the dependency rule is enforceable.
Practical enforcement I like at mid-size:
- Lint rules or package boundaries that forbid deep imports across features
- A short OWNERSHIP doc per feature: public exports, on-call, and what “done” means
- Colocated tests that cannot import another feature’s internals
- Storybook or preview entry points that render a feature without booting the universe
If your monorepo tool can express package boundaries, use them. If it cannot, start with lint and code review until the pain justifies tooling.

Data fetching belongs behind a door
A common million-line path is hooks that are half React Query and half business policy. My rule: the hook returns data and mutation handles. Policy that decides whether a user may see a refund action lives in domain logic or a thin feature facade — not buried under loading spinners.
That separation pays off when you add a second client (admin app, native shell, server render). If the only place the rule exists is a JSX ternary, you will duplicate it wrong.
When the stack is part of the problem
I am not saying React is sacred. Choose something else when the rendering model fights your product: heavy offline, highly reactive canvases, or a team that already ships faster in another ecosystem. Just separate that decision from the mud.
Ask: if we rewrote this in framework X next quarter, would the module graph still be illegal? If yes, you have a boundaries problem. Fix that first or you will pay for two migrations.
A recovery plan that does not require a big bang
When I inherit the mud, I do not announce a year-long rewrite. I pick a strangler path:
- Identify the hottest feature (most changes, most incidents, most fear).
- Define its public exports and freeze deep imports inbound.
- Extract domain rules out of JSX as they get touched.
- Normalize data-access patterns for that feature only.
- Repeat on the next hotspot.
Leave cold code alone until it becomes hot. Perfect architecture for abandoned pages is how teams lose trust in the recovery.
State management is usually a boundary costume
When the app hurts, teams reach for a new global store. I have done that. Sometimes it helps. Often it creates a new shared kernel that every feature can illegally know about.
My bias: keep server state in the data-access layer with explicit cache keys, keep UI state local until multiple distant screens truly share it, and treat “global store” as a last resort with an owners list. If your Redux/Zustand tree knows about invoices, onboarding, and tooltip dismissals in one blob, you did not centralize state. You centralized coupling.
Context is the same trap in a friendlier jacket. A SessionProvider that starts as auth and grows into feature flags, locale, billing tier, and theme tokens becomes an import magnet. Split providers by change rate and audience. Auth changes rarely. Tooltip prefs change constantly. They should not share a render blast radius.
Performance problems that are really seam problems
Slow deploys and sluggish interaction are often blamed on React itself. Profile first. Then ask whether your component tree re-renders because props are unstable across a boundary that should have been memoized at the data edge — or because a god-hook invalidates half the app on every keystroke.
Boundaries help performance indirectly: smaller public surfaces make it obvious what can change, what should be memoized, and what can be code-split. A feature that cannot be lazy-loaded without dragging three siblings is telling you the graph is wrong.
I also watch for “shared component” packages that re-export the world. A design system should be boring. The moment it needs the user’s subscription object to render a badge, it stopped being a design system.
How I talk about this with product and backend
Frontend boundaries fail when product slices do not match team slices. If “Growth” and “Billing” are different roadmaps but share one undifferentiated SPA folder, engineers will invent illegal shortcuts to ship. The fix is partly technical and partly organizational: give features missions that match the folders you expect them to respect.
Backend contracts matter too. If every screen fans out into fifteen microservice calls with ad hoc shaping in hooks, your React code becomes a BFF by accident. Sometimes the right boundary is a real BFF or aggregated endpoint. Pushing composition to the client is fine until the client becomes the integration layer nobody owns.
A checklist I keep near the PR template
- Does this PR import another feature’s non-public path?
- Did domain rules move into JSX instead of a testable function?
- Did we add to a shared util that already knows too many nouns?
- Can this feature’s main screen render in isolation with mocked data access?
- If we deleted this feature folder, would the rest of the app compile?
That last question is rude and useful. Features should be removable in theory. If deleting Billing breaks Onboarding because of a sneaky import, you found the seam you needed yesterday.
The line, in one sentence
If a file needs to know another feature’s private guts to render a screen, the stack is not too big — the seam is missing. Draw the seam, export a small public surface, and keep React in the job it is good at: composing UI over clear inputs. A million lines can be healthy. A million lines with no boundaries is a prediction of slower change, scarier refactors, and a rewrite pitch that will not save you.