Most React codebases start clean. A handful of components, a couple of hooks, state that lives exactly where it's used. The trouble rarely shows up in month one — it shows up eighteen months in, when a change to one screen quietly breaks three others, and nobody can say why without reading the whole file tree first.
Where React apps start to hurt
The pattern is almost always the same. Components grow because it's easier to add one more prop than to draw a new boundary. State gets lifted higher "just in case" until a handful of top-level components own state that half the application depends on. Shared logic gets copied instead of extracted, because extracting it properly takes longer than the deadline allows. None of these decisions look wrong in isolation. Compounded across a few hundred components, they add up to an application where every change carries hidden risk.
Component boundaries are ownership boundaries
A component boundary is a promise about who owns what. When that promise is clear, a change inside a component is safe to make without reading the rest of the tree. When it isn't, every change requires tracing props and side effects across files that have no obvious relationship to each other.
- A component should own its own state unless another component genuinely needs to react to it.
- Props are a contract — if a component needs more than five or six, it is usually doing more than one job.
- Presentation and data-fetching are separate concerns, even when it's tempting to fetch inside a component "just this once."
- A shared component used in three different contexts needs to be designed for all three from the start, not patched after the third use case breaks it.
State belongs where the source of truth lives
Most of the state-management debates in React are really debates about where the source of truth lives, not which library to use. Local UI state — a toggle, a form field, a hover state — belongs in the component that renders it. Data that multiple unrelated parts of the application need belongs in a store, a server cache, or a context provider designed for that purpose. The failure mode I see most often is the opposite: local state pushed upward until a single provider is responsible for everything, and every component subscribes to all of it just to read one field.
Reusable is not the same as generic
Teams often reach for abstraction earlier than the codebase needs it. A component built to handle every future variation is usually harder to change than three separate components that each do one thing clearly. Reuse should be extracted after a pattern repeats — not designed in advance for a pattern that might repeat. The difference between reusable and generic is that a reusable component solves a real, observed problem; a generic one solves an imagined one, with a configuration API that grows every time reality disagrees with the original assumption.
Patterns that hold up over time
- Colocate files by feature, not by type — a feature's component, styles, hooks and tests belong together.
- Keep components small enough that a new engineer can understand one in under a minute.
- Treat prop drilling past two levels as a signal to introduce composition or context, not a reason to add a global store.
- Write the integration between two features explicitly, rather than letting them reach into each other's internals.
- Review architecture decisions the same way you review code — in isolation, they always look reasonable; the risk is in the accumulation.
None of this is about picking the "correct" pattern up front. It's about making boundaries visible enough that six months from now, whoever touches the code — including you — can trust what they're looking at without having to read everything around it.