Introduction
React's programming model is deceptively simple: components take props, hold some state, and return UI. That simplicity is exactly why so many React codebases become unmanageable within a year or two. The framework does not enforce a folder structure, does not mandate how state should flow, and does not stop you from putting business logic directly inside a button's onClick handler. Every architectural decision is left to the team, and teams under deadline pressure tend to make the same set of expedient choices that eventually calcify into technical debt.
This article is a working reference for engineers who already know React's syntax and want to get serious about how they structure it. We will move from foundational composition patterns through to advanced techniques used in large production codebases, then spend meaningful time on the pitfalls and antipatterns that quietly erode maintainability. The goal is not to hand you a rigid rulebook - React explicitly avoids being opinionated about architecture, unlike frameworks such as Angular or Ember - but to give you the vocabulary and judgment to make deliberate trade-offs instead of accidental ones.
Why Component Architecture Becomes a Problem
Most React projects start clean. A handful of components, a couple of useState calls, everything fits in your head. The trouble begins as the application grows along three axes simultaneously: the number of components, the number of ways those components can interact, and the number of engineers touching the code. Complexity in software systems tends to grow combinatorially with these axes rather than linearly, which is why a codebase that felt fine at 50 components can feel unworkable at 500 even though nothing individually seems wrong.
The specific symptoms are familiar to anyone who has worked on a mature React app. Prop drilling forces you to thread a value through five layers of components that have no use for it themselves. A single component accumulates a dozen useEffect hooks that each depend on slightly different pieces of state, making it nearly impossible to reason about execution order. State that should live close to where it is used instead gets hoisted to a global store "just in case," and now every unrelated feature re-renders when one piece of that store changes.
None of these problems come from React itself being flawed. They come from architecture decisions - or the absence of them - made early in a project's life and never revisited. Component architecture is the discipline of making those decisions intentionally: deciding where state lives, how data flows, how responsibilities are separated, and how much abstraction a given problem actually deserves.
Core Concepts: The Building Blocks of Component Design
Presentational vs. Container Components
The presentational/container split, popularized early in React's history by Dan Abramov, separates components into two categories: those that know how to display data (presentational) and those that know how to fetch or manage it (container). A presentational component receives everything through props and has no awareness of where the data came from. A container component owns state, side effects, and data-fetching logic, and passes the results down.
This pattern has fallen out of strict fashion since the introduction of hooks, because custom hooks now do much of the job that container components used to do, without forcing an extra layer of JSX nesting. But the underlying principle - separating "how something looks" from "where its data comes from" - remains one of the most useful mental models in React architecture. You will still see it referred to as separating "smart" and "dumb" components.
Composition Over Inheritance
React explicitly favors composition over inheritance, and the official React documentation states this directly: there is no supported way to create "component inheritance hierarchies" in React, and composition consistently solves the problems that inheritance solves in class-based UI frameworks. In practice, this means you build complex components by combining smaller ones through props and children, rather than by extending a base component class.
The children prop is the simplest form of composition and is often underused by teams that default to passing configuration objects instead. A Card component that accepts children is more flexible than one that accepts a title string and a body string, because the former lets the consumer render arbitrary JSX - including other components - inside the card, while the latter locks the card into a single fixed shape.
Unidirectional Data Flow
Data in React flows in one direction: from parent to child via props. State changes happen where the state is owned, and children communicate upward by calling functions passed down to them as props, not by mutating parent state directly. This constraint is what makes React applications predictable to debug - you can always trace a rendered value back to the state that produced it by walking up the component tree.
Understanding this flow is prerequisite to almost every other pattern in this article. Context, reducers, and external state managers all exist to solve specific pain points that show up when unidirectional flow requires passing props through many layers of components that do not otherwise need them.
Implementation Patterns in Practice
Compound Components
Compound components let a set of related components share implicit state without the consumer having to wire that state together manually. The classic example is a Select or Tabs component where the parent manages which item is active and child components read that state through context rather than receiving it as an explicit prop.
// Tabs.tsx - a compound component using context for implicit state sharing
import { createContext, useContext, useState, ReactNode } from "react";
interface TabsContextValue {
activeIndex: number;
setActiveIndex: (index: number) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error("Tabs.* components must be rendered inside <Tabs>");
}
return ctx;
}
export function Tabs({ children, defaultIndex = 0 }: { children: ReactNode; defaultIndex?: number }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
export function TabList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
export function Tab({ index, children }: { index: number; children: ReactNode }) {
const { activeIndex, setActiveIndex } = useTabsContext();
return (
<button
role="tab"
aria-selected={activeIndex === index}
onClick={() => setActiveIndex(index)}
>
{children}
</button>
);
}
export function TabPanel({ index, children }: { index: number; children: ReactNode }) {
const { activeIndex } = useTabsContext();
return activeIndex === index ? <div role="tabpanel">{children}</div> : null;
}
This pattern buys you an API that reads declaratively - <Tabs><TabList><Tab index={0}>... - while keeping the coordination logic hidden inside the component group. It is the same idea behind the native HTML <select> and <option> relationship, and it is used extensively in headless UI libraries such as Radix UI and React Aria.
Custom Hooks for Logic Reuse
Custom hooks are the primary mechanism for sharing stateful logic across components without changing the component tree's shape, which was the main limitation of the older higher-order component and render props patterns. A custom hook is just a function whose name starts with use and which may call other hooks internally.
// useDebouncedSearch.ts - encapsulates debounce + fetch + cancellation logic
import { useEffect, useState } from "react";
interface SearchResult {
id: string;
label: string;
}
export function useDebouncedSearch(query: string, delayMs = 300) {
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data: SearchResult[] = await response.json();
setResults(data);
} catch (err) {
if ((err as Error).name !== "AbortError") {
console.error("Search failed", err);
}
} finally {
setIsLoading(false);
}
}, delayMs);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [query, delayMs]);
return { results, isLoading };
}
Extracting this logic into a hook means the component using it - a search bar, a command palette, an autocomplete field - stays focused on rendering, while the debounce timing, request cancellation, and loading state are handled once and reused everywhere search-like behavior is needed. This is arguably the single highest-leverage refactor available in most React codebases: identifying repeated useEffect and useState combinations across components and consolidating them into a named hook.
Advanced Patterns for Scaling Applications
As applications grow past a handful of features, component-level patterns stop being sufficient on their own, and you need patterns that operate at the level of the whole application. Feature-based folder structures are one such pattern: instead of organizing files by type (components/, hooks/, utils/), you organize by feature or domain (features/checkout/, features/user-profile/), with each feature folder containing its own components, hooks, and types. This keeps related code physically close together and makes it obvious what can be deleted when a feature is removed, which is far harder to determine when files are scattered across type-based folders.
State colocation is the related principle at the component level: state should live as close as possible to the components that use it, and should only be lifted upward when multiple components genuinely need to share it. A common architectural mistake is reaching for a global state library the moment two components need the same piece of data, when a shared parent component and prop passing would have been sufficient. Libraries like Zustand, Jotai, and Redux Toolkit are valuable, but they solve a specific problem - state that needs to be accessed by many unrelated parts of the tree - not a general one.
For state that involves complex transitions - multi-step forms, checkout flows, media players - the useReducer hook combined with a well-defined action type gives you an explicit, testable state machine instead of a tangle of boolean flags like isLoading, hasError, and isSubmitted that can drift into impossible combinations.
// checkoutReducer.ts - a reducer modeling explicit states instead of boolean flags
type CheckoutState =
| { status: "idle" }
| { status: "validating" }
| { status: "submitting" }
| { status: "success"; orderId: string }
| { status: "error"; message: string };
type CheckoutAction =
| { type: "VALIDATE" }
| { type: "SUBMIT" }
| { type: "SUCCESS"; orderId: string }
| { type: "FAIL"; message: string }
| { type: "RESET" };
function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
switch (action.type) {
case "VALIDATE":
return { status: "validating" };
case "SUBMIT":
return { status: "submitting" };
case "SUCCESS":
return { status: "success", orderId: action.orderId };
case "FAIL":
return { status: "error", message: action.message };
case "RESET":
return { status: "idle" };
default:
return state;
}
}
The advantage here is structural: the type system prevents you from ever being in status: "success" without an orderId, or checking isLoading while hasError is also true. This approach is closely related to formal state machine libraries such as XState, which extend the same idea with guards, hierarchical states, and visualizable transition graphs, and is worth adopting even without a dedicated library once a flow has more than three or four distinct states.
Trade-offs and Common Pitfalls
Every architectural pattern discussed above solves a real problem, but each also introduces a cost, and the failure mode in most teams is applying the pattern regardless of whether the problem it solves is actually present. Compound components, for instance, are excellent for genuinely coupled UI like tabs or accordions, but they add an indirection tax - a new engineer has to understand the implicit context contract before they can safely modify the component - that is not worth paying for a simple form with three independent fields. The right question before reaching for any pattern in this article is not "is this a good pattern" but "does this component actually have the problem this pattern solves."
Context is probably the most frequently misapplied tool in the React ecosystem. It was designed to avoid prop drilling for genuinely global concerns - theme, authenticated user, locale - and it works well for values that change infrequently. When it is used for frequently changing state, such as form field values or a live data feed, every component that consumes that context re-renders on every update, because context does not support the same fine-grained subscription model that dedicated state libraries provide. Teams that put a large, frequently updated object into a single context often see performance degrade in ways that are hard to trace back to the cause, because the re-renders are scattered across the tree rather than localized to one obviously slow component.
Custom hooks carry a subtler risk: they can hide side effects behind an innocuous-looking function call. A component that calls useAnalyticsTracking() looks the same whether that hook is a pure computation or one that fires a network request on every render. This is not a reason to avoid custom hooks - they remain one of the best tools available - but it is a reason to name them precisely and document what they do, especially when they perform I/O, because the abstraction that makes hooks powerful is the same one that makes their behavior easy to misjudge from the call site.
Antipatterns to Actively Avoid
Beyond trade-offs that require judgment, there is a smaller set of patterns that are close to universally worth avoiding, because the cost consistently outweighs any benefit. The first is the "god component": a single component, often a top-level page, that owns dozens of pieces of state, fetches multiple unrelated resources, and renders a deeply nested tree of conditional JSX. These components are identifiable by files that exceed several hundred lines and by pull requests where a change to one feature within the component risks breaking an unrelated feature in the same file. The fix is almost always decomposition along the boundaries of what the component is actually doing - extract the data-fetching into hooks, extract visually and logically independent sections into their own components, and let the parent do orchestration rather than implementation.
The second is prop drilling combined with unnecessary re-renders. Passing a value through four layers of components that do not use it is a structural smell, but the deeper issue is what it implies about component boundaries: if a value genuinely needs to reach a deeply nested component, either that component should be composed differently (rendered closer to where the value lives, using children or slots) or the value belongs in context. Passing it through five prop layers "because that's how it's always been done" is rarely the right long-term answer.
A third common antipattern is deriving state that should be computed. Storing a filtered or sorted version of a list in useState, and then trying to keep it synchronized with the source list via a useEffect, introduces a class of bugs where the derived state silently falls out of sync with its source - a stale filter after the underlying list changes, a sort order that persists after data was refreshed. The correct approach in the vast majority of cases is to compute the derived value directly during render, optionally memoized with useMemo if the computation is expensive, rather than storing it in state at all. React's own documentation explicitly calls this out as one of the most common mistakes with useEffect.
Finally, using useEffect as a general-purpose "run this when something changes" mechanism, rather than specifically for synchronizing with external systems, is an antipattern that has become common enough that the React team added a dedicated section to the documentation addressing it directly. Effects that exist purely to update local state in response to a prop change, or to call a parent callback after a state update, are almost always signs that the logic belongs directly in the event handler that caused the state change in the first place.
Best Practices for Sustainable React Architecture
Given the trade-offs above, a small number of practices consistently produce better outcomes across different team sizes and project types. Start by keeping state as local as possible for as long as possible, and only lift it when two or more components have a genuine, current need to share it - not a hypothetical future one. This single habit prevents the majority of unnecessary context usage and global state library adoption seen in the wild.
Second, treat component boundaries as an interface design problem, not a file-splitting problem. A well-designed component has a small, stable prop surface and hides its internal complexity, in the same way a well-designed function hides its implementation behind a clear signature. If you find yourself passing more than five or six props to a component regularly, or passing configuration objects that mirror internal implementation details, that is usually a sign the component is doing too much or exposing too much of how it works.
Third, invest in a small number of custom hooks that encapsulate your application's recurring stateful patterns - data fetching with loading and error states, form field management, debounced input, pagination - rather than rewriting the same useEffect logic in every component that needs it. This is where the majority of long-term maintenance savings in a React codebase actually come from, more so than any single high-level architectural decision.
Fourth, adopt TypeScript, or at minimum PropTypes, for anything beyond a prototype. The compile-time guarantees around prop shapes and component contracts catch an entire category of bugs before they reach a code review, and they make the compound component and reducer patterns discussed earlier significantly safer to refactor.
Finally, write component-level tests that exercise behavior rather than implementation. Testing that clicking a button calls the expected callback, or that a form shows a validation message under specific input, remains valid even after you refactor the component's internals - whether it uses a reducer or plain useState, whether it's a compound component or a single component with props. Tests that assert on internal state shape or component structure break on every refactor regardless of whether the behavior changed, which discourages the very refactoring that keeps architecture healthy over time.
Key Takeaways
- Keep state local by default. Only lift state to a shared parent or context when there is a current, concrete need - not a speculative future one.
- Use composition and
childrenbefore configuration props. A component that acceptschildrenis more flexible than one with a rigid, fixed prop shape. - Extract repeated stateful logic into named custom hooks. This is the highest-leverage refactor available in most React codebases and reduces duplicated
useEffectpatterns. - Model complex flows as explicit state machines using
useReduceror a library like XState, instead of combinations of independent boolean flags. - Never use
useEffectto derive state that can be computed during render. Compute it directly, and memoize withuseMemoonly if the computation is measurably expensive.
A Mental Model: Components as Contracts
It helps to think of every component not as a chunk of markup, but as a contract between whoever wrote it and whoever consumes it. The props are the terms of that contract - what the caller must provide and what they can expect in return. A component with a clean, small, well-typed prop interface is a contract that's easy to read and safe to depend on. A component with fifteen optional props, several of which only make sense in combination with each other, is a contract full of fine print, and every consumer has to read the implementation to understand what's actually being promised.
This framing also clarifies why some patterns exist. Compound components extend the contract across multiple components that must be used together, similar to how a set of related API endpoints form a contract as a group rather than individually. Context extends the contract implicitly to an entire subtree, which is powerful but means the contract is no longer visible at the call site - you have to trust that the provider is somewhere above you in the tree. Every architectural choice, viewed this way, is really a decision about how explicit versus implicit you want a given contract to be, and explicit contracts are almost always easier to maintain, even when they require slightly more code to write.
Conclusion
React does not prescribe an architecture, and that is both its greatest strength and the reason so many codebases drift into disorder. The patterns covered here - composition, compound components, custom hooks, colocated state, and explicit state machines - are not a checklist to apply uniformly, but a set of tools matched to specific problems. The engineering judgment that matters most is recognizing which problem you actually have before reaching for a pattern designed to solve it.
Teams that build durable React applications tend to share a few habits rather than a single grand architecture: they keep state as close as possible to where it's used, they treat component props as a real interface worth designing carefully, and they refactor toward custom hooks and clearer state models as soon as duplication or ambiguity appears, rather than waiting for it to compound. None of this requires an exotic toolchain - it requires applying React's own primitives deliberately, and being honest about when a component has quietly become a place where too many concerns collide.
References
- React Documentation - Thinking in React: https://react.dev/learn/thinking-in-react
- React Documentation - You Might Not Need an Effect: https://react.dev/learn/you-might-not-need-an-effect
- React Documentation - Passing Data Deeply with Context: https://react.dev/learn/passing-data-deeply-with-context
- React Documentation - Extracting State Logic into a Reducer: https://react.dev/learn/extracting-state-logic-into-a-reducer
- React Documentation - Reusing Logic with Custom Hooks: https://react.dev/learn/reusing-logic-with-custom-hooks
- React Documentation - Composition vs Inheritance: https://legacy.reactjs.org/docs/composition-vs-inheritance.html
- Kent C. Dodds - Compound Components: https://kentcdodds.com/blog/compound-components-with-react-hooks
- Kent C. Dodds - Application State Management with React: https://kentcdodds.com/blog/application-state-management-with-react
- XState Documentation: https://stately.ai/docs
- Radix UI Documentation (example of compound component patterns in practice): https://www.radix-ui.com/primitives/docs/overview/introduction