React Component Patterns
- react
- typescript
- javascript
- webdev
- frontend
- softwareengineering
- designpatterns
The previous article in this series covered how to split a bundle so users only download what a route actually needs. That question, what belongs together and what doesn't, applies just as much inside a component tree as it does at the bundle level. Once you've decided how code gets loaded, the next question is how it should be organized once it's there.
This is part of the React & Next.js Complete Guide, and it sits at a point in the series where the mechanics are settled: you know how rendering works, how hooks behave, and how to keep a bundle lean. Component patterns are where those mechanics turn into a codebase other engineers can actually work in without re-deriving your intent every time they open a file.
None of what follows is exotic. These are patterns that show up in almost every production React codebase I've worked with. Knowing they exist isn't the hard part. The hard part is knowing when each one earns its complexity and when it's just ceremony.
Composition over configuration
The most common mistake I see in component design is treating props as a configuration API instead of a composition API. A component starts with three props, then someone needs a slightly different header, so it gets a headerText prop. Then someone needs an icon next to the header, so it gets headerIcon. Eighteen months later the component has thirty props, half of them booleans that toggle unrelated pieces of markup, and nobody can tell which combinations are actually supported.
The fix is usually to pass JSX instead of configuration:
type CardProps = {
header: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
};
function Card({ header, children, footer }: CardProps) {
return (
<div className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">{header}</div>
<div>{children}</div>
{footer && <div className="mt-3 border-t pt-2">{footer}</div>}
</div>
);
}
function OrderCard({ order }: { order: Order }) {
return (
<Card
header={
<>
<h3 className="font-semibold">{order.customerName}</h3>
<StatusBadge status={order.status} />
</>
}
footer={<OrderActions orderId={order.id} />}
>
<OrderLineItems items={order.lineItems} />
</Card>
);
}
Card no longer needs to know anything about badges, actions, or line items. It owns layout; the caller owns content. The tradeoff is real: a configuration prop like variant="danger" is easier to grep for and easier to constrain with a type union than an arbitrary React.ReactNode. Use configuration props for a closed set of variants you control (size, color, alignment), and composition for anything that's genuinely open-ended content. Mixing the two in the same prop, a header prop that's sometimes a string and sometimes a node with special-cased rendering logic inside the component, is where this pattern goes wrong.
Container and presentational, without the dogma
The container/presentational split, data-fetching components on one side, pure rendering components on the other, was popular before hooks existed, largely because there was no other clean way to separate "get data" from "render data" without either prop drilling everywhere or reaching for a class component with lifecycle methods doing double duty.
Hooks removed the structural reason for the pattern, but the underlying idea is still worth keeping: a component that fetches, mutates, or subscribes to something external should be a thin layer around a component that only knows how to render props. You don't need two separate files and a rigid naming convention for this anymore.
function useOrderDetails(orderId: string) {
const [order, setOrder] = useState<Order | null>(null);
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
useEffect(() => {
let cancelled = false;
setStatus("loading");
fetchOrder(orderId)
.then((data) => {
if (!cancelled) {
setOrder(data);
setStatus("idle");
}
})
.catch(() => {
if (!cancelled) setStatus("error");
});
return () => {
cancelled = true;
};
}, [orderId]);
return { order, status };
}
function OrderDetailsPage({ orderId }: { orderId: string }) {
const { order, status } = useOrderDetails(orderId);
if (status === "loading") return <OrderSkeleton />;
if (status === "error") return <OrderLoadError orderId={orderId} />;
if (!order) return null;
return <OrderDetailsView order={order} />;
}
useOrderDetails owns fetching and status. OrderDetailsView never needs to know a network request exists; it's testable with plain props and a snapshot test, no mocking fetch. The pitfall here is stopping halfway: writing the hook but still threading loading and error state as booleans through five layers of presentational components instead of resolving them once, near the top, and handing the rendering component a single settled shape to work with.
Sharing logic: hooks first, then render props, then HOCs
React has offered three ways to share logic across components over its history, and the order they appeared in is roughly the order you should reach for them today, inverted.
Custom hooks are the default now. They compose cleanly, they don't add wrapper components to the tree, and TypeScript can infer their return types without extra generics gymnastics. useOrderDetails above is a custom hook doing exactly this job.
Render props still earn their place when the shared behavior needs to control what gets rendered, not just provide data, and when that control needs to be dynamic per call site in a way a hook's return value can't express cleanly. A virtualized list that hands you a renderItem slot per visible row is a reasonable use of the pattern, though in most such cases a children function serves the same purpose without a dedicated prop name.
Higher-order components are the one I'd avoid reaching for on a new codebase. They wrap a component in another component, which adds a layer to the tree, obscures prop types (you end up writing WithAuth<Props> generics to keep the wrapped component's prop type visible), and makes debugging harder because the component you're looking at in DevTools isn't the one that actually renders. If you're maintaining an older codebase that already uses HOCs for cross-cutting concerns like auth gating, that's a reasonable case to leave alone rather than a mandate to rewrite; just don't add new ones.
Controlled versus uncontrolled, and why it matters beyond forms
Controlled and uncontrolled are usually introduced as a forms concept: does the component hold its own state (uncontrolled), or does the parent own the state and pass it down along with a change handler (controlled)? The distinction matters for any component with internal state that a parent might reasonably need to read or drive, not just inputs.
type ToggleProps = {
checked?: boolean;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
};
function Toggle({ checked, defaultChecked, onChange }: ToggleProps) {
const [internalChecked, setInternalChecked] = useState(defaultChecked ?? false);
const isControlled = checked !== undefined;
const isChecked = isControlled ? checked : internalChecked;
function handleClick() {
const next = !isChecked;
if (!isControlled) setInternalChecked(next);
onChange?.(next);
}
return (
<button role="switch" aria-checked={isChecked} onClick={handleClick}>
{isChecked ? "On" : "Off"}
</button>
);
}
This dual-mode pattern is what libraries like Radix and Headless UI use throughout their component sets, and it's worth adopting for any reusable component in your own design system: consumers who don't care about the state can drop the component in with a defaultChecked and move on, while consumers who need to sync it with a form library or server state can pass checked and onChange and drive it fully. The pitfall is doing this halfway, accepting a checked prop but never actually reading it in the render, which silently breaks the controlled case and is a genuinely unpleasant bug to track down because the component looks correct until you inspect it against the value you passed in.
Knowing when a component should split
None of these patterns tell you when to break a component apart in the first place. A few signals I actually trust: a component's prop list needs a comment section to stay readable, a component holds state that only some of its rendered output cares about, or you find yourself passing a boolean whose only job is to hide a whole subtree that could be its own component instead. None of these are hard rules with a threshold number, and reasonable engineers will draw the line in different places on the same component. The cost of splitting too early is indirection you don't need yet; the cost of splitting too late is a component nobody wants to touch. Given the choice, I'd rather split once the second use case shows up than guess at reusability on the first one.
Key takeaways
- Prefer composition (children, slot props) for open-ended content, and reserve configuration props for a closed set of variants you actually control.
- The container/presentational idea is still useful even though hooks removed the need for two separate component files to implement it.
- Default to custom hooks for shared logic. Reach for render props only when the shared behavior needs to control rendering dynamically, and treat HOCs as legacy rather than a pattern to introduce fresh.
- Support both controlled and uncontrolled usage for reusable components with internal state, and make sure the controlled path is actually wired through, not just accepted and ignored.
- Split a component when its prop list, internal state, or conditional rendering stops being something you can hold in your head, not on a fixed size threshold.
Frequently asked questions
When should I use a render prop instead of a custom hook?
When the shared logic needs to control what gets rendered per call site, not just hand back data or state. If you only need values and functions, a hook is simpler and doesn't add a wrapper component to the tree.
Are higher-order components deprecated in React?
No, but they've fallen out of favor because hooks solve the same logic-sharing problem without wrapping components or obscuring prop types. Existing HOC-based code doesn't need to be rewritten just for the sake of it; new logic sharing should default to hooks.
How many props are too many for a single component?
There's no fixed number. Watch for props that only make sense in combination with other props, or a prop list that needs grouping comments to stay readable. Both are signs the component is doing more than one job.
Should every reusable component support both controlled and uncontrolled modes?
Only if it manages internal state that some consumer might reasonably need to own instead, like a toggle, an accordion, or a tab set. A component with no meaningful internal state, like a button, doesn't need this pattern at all.
Is the container/presentational pattern still relevant with Server Components?
The underlying idea, separate data access from rendering, actually maps well onto Server Components fetching data and passing it down to client components that render it. The mechanism changed; the reason to separate the concerns didn't.
What's the actual cost of splitting a component too early?
Mostly indirection: extra files, extra prop-passing, and a reader who now has to jump between components to understand one piece of behavior. It's a real cost, just usually a smaller one than the cost of a component that's grown too many responsibilities to touch safely.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.