React component architecture that actually scales.
Most React codebases don't fail because of bad logic. They fail because components grow into tangled monoliths that nobody wants to touch. Here's how I structure projects to avoid that.
React gives you almost no opinions about how to structure your application. That's marketed as a feature. In practice, it's a trap. Every team I've joined has had a different folder structure, different naming conventions, and a different definition of what a "component" even is. The result is predictable: six months in, you're staring at a 500-line component with 12 props, three nested ternaries, and a useEffect that fetches data, transforms it, and sets three different pieces of state.
The problem isn't React. The problem is that "everything is a component" sounds like simplicity but actually requires discipline to execute well. Without explicit architectural boundaries, every developer on the team invents their own — and those inventions rarely agree with each other.
The problem with "just components"
React's component model is powerful precisely because it's flexible. But flexibility without structure is just chaos with good marketing. When there's no shared opinion about what a component should contain, you end up with files that are simultaneously responsible for layout, data fetching, business logic, error handling, and presentation.
I've seen components that import 15 other components, accept a dozen props, manage five pieces of local state, and have three useEffects that subtly depend on each other's timing. The developer who wrote it understood the flow perfectly. Everyone else treats it like a black box they're afraid to open.
The "everything is a component" philosophy is technically true — everything in React is a component. But that doesn't mean every component should do everything. A React component without clear boundaries is just a function that returns JSX and prays. The question isn't whether to use components. It's how to decide what each component is for.
Three layers I use in every project
After working across a dozen React codebases — some greenfield, some legacy rescues — I've settled on a three-layer architecture that scales from side projects to production apps with 50+ routes. It's not original, but it works.
- UI primitives. These are your Button, Input, Card, Modal, Badge, and Tooltip components. They contain zero business logic. They accept styling props, children, and event handlers. They know nothing about your domain. You should be able to copy them into a completely different project and they'd work unchanged.
- Feature components. These are domain-specific: LoginForm, ProjectCard, InvoiceTable, UserAvatar. Each one owns exactly one feature. It composes UI primitives, contains the business logic for that feature, and manages its own local state. One feature, one file (or one folder with an index).
- Page components. These are orchestrators. A page component composes feature components, handles layout, and manages route-level concerns like data fetching and error boundaries. It should read like a table of contents for the page: you glance at it and know what the user sees.
The folder structure reflects this directly:
- src/components/ui/ — Primitives (Button, Input, Card, Modal)
- src/components/features/ — Feature components (LoginForm, ProjectCard)
- src/pages/ — Page-level orchestrators
- src/hooks/ — Shared custom hooks
- src/utils/ — Pure utility functions
- src/types/ — Shared TypeScript types
The rule is simple: each layer can import from the layer below it, but never from the layer above. A Button never imports a LoginForm. A LoginForm never imports a DashboardPage. Violations of this rule are the first sign that a component is doing too much.
Composition over configuration
The most common mistake in React architecture is building mega-components that try to handle every use case through props. You've seen this: a Modal component with props for title, subtitle, icon, primaryAction, secondaryAction, size, variant, showCloseButton, onClose, closeOnOverlayClick, closeOnEscape, footer, and header. Twelve props. One component. A maintenance nightmare.
The alternative is composition. Instead of configuring a component through props, you build it from smaller pieces that snap together:
-
Instead of
<Modal title="Confirm" subtitle="Are you sure?" primaryAction="Delete" onPrimary={handleDelete} variant="danger" showClose={true} /> -
You write
<Modal><Modal.Header>Confirm</Modal.Header><Modal.Body>Are you sure?</Modal.Body><Modal.Footer><Button variant="danger" onClick={handleDelete}>Delete</Button></Modal.Footer></Modal>
The composed version is more lines of code. It's also dramatically easier to
customize, extend, and reason about. Each sub-component has a single responsibility.
When you need a modal with a form inside it, you don't add a formFields
prop — you just put a form in <Modal.Body>. The component doesn't
need to anticipate every use case because the consumer controls the structure.
This pattern — using children, compound components, and render props
instead of configuration props — is the single biggest lever for keeping React
codebases maintainable. It trades a small amount of verbosity for a massive
reduction in component complexity.
Prop drilling is a design smell, not a React problem
When developers encounter prop drilling — passing data through three or four layers of components — the instinct is to reach for Context or a state management library. Sometimes that's the right call. More often, it's treating a symptom while ignoring the disease.
Prop drilling usually means your component tree doesn't match your data flow. You have a piece of state at the top that's needed at the bottom, and four intermediate components are passing it through without using it. The solution isn't to teleport the data with Context. The solution is to ask: why are those four components in the way?
Often the answer is that the tree is too deep. Components are nested for visual reasons (layout) when they could be siblings. Or a parent component is doing too much and could be split so the data consumer is closer to the data provider. Before you reach for global state, try restructuring your component tree. Move the state closer to where it's used. Lift the consuming component up. Flatten the hierarchy.
Context is a tool for truly cross-cutting concerns: theme, locale, authentication status. When you start putting feature-specific data in Context — the current invoice, the selected project, the form validation state — you're building a global state management system one context provider at a time. And you'll end up with the same problems Redux was invented to solve, minus the dev tools.
The rule of three
Don't abstract until you've copy-pasted three times. This is the single most underrated rule in software development, and it applies to React components more than anywhere else.
The first time you write something, you write it. The second time, you notice the similarity and feel the itch to abstract. Resist it. The second instance doesn't give you enough information about what the real interface should be. You'll abstract too early, bake in assumptions from the first two use cases, and then spend the next six months adding escape hatches.
The third time you write the same pattern, the abstraction reveals itself. You can see what's truly shared and what's incidentally similar. The third instance tells you what the props should be, what should be configurable, and — critically — what should be left out.
The best React codebase I've ever worked in had zero clever abstractions. It had clear boundaries, boring names, and components you could delete without fear.
Premature abstraction is worse than duplication. Duplication is obvious and local — you can see it, and you can fix it later. A bad abstraction is subtle and global — it shapes every future decision, and undoing it means touching every file that depends on it. When in doubt, duplicate. When the pattern is clear, abstract.
Closing thought
Components are not just a code organization tool. They're a communication device.
A well-named, well-bounded component tells the next developer what the product does
without them ever reading a spec or a ticket. <InvoiceLineItemEditor>
tells you more than any comment ever could.
The best React architectures I've worked in don't feel clever. They feel obvious. You open a file and know immediately where you are, what it does, and where to make your change. That's not a lack of sophistication — it's the highest form of it.
Write components like documentation. Name them like headlines. Bound them like chapters. The codebase that reads like a well-organized book is the one that survives the team that wrote it.