Component Design Patterns: Composition, Compound Components, Render Props, and Headless UI
Learn the vocabulary and mechanics of the major React component design patterns — composition, compound components, render props, headless components, the provider pattern, and controlled/uncontrolled components — and understand why higher-order components are now considered a legacy pattern that hooks and composition have largely replaced.
As a component library grows, the same handful of design problems keep showing up: how do you let a consumer customize a component's internals without exploding its prop list, how do you share state between a group of related components without prop-drilling it through every layer, and how do you separate 'what a component does' from 'how it looks'. React's component model does not hand you one official answer to these problems — instead, a set of named patterns has emerged from the community, each suited to a different kind of flexibility. Knowing their names and tradeoffs lets you recognize them in library source code (React Router, Radix UI, Downshift, React Table) and choose the right one instead of reinventing an awkward version of it.
Composition over configuration
The single most important idea in React component design is composition: build complex UIs by nesting simple components inside each other via the children prop, rather than by adding more and more configuration props to one giant component. A <Card title="..." footer={...} icon={...} onIconClick={...} /> component with a dozen props is fighting the grain of React; a <Card><Card.Header>...</Card.Header><Card.Body>...</Card.Body></Card> that accepts arbitrary children is working with it.
This is sometimes called 'slot-like' composition, because each nested region behaves like a named slot the consumer fills in with whatever markup they want, instead of a prop that only accepts a string or a fixed shape of data. The children prop is React's default, unnamed slot; when a component needs more than one region (a header slot and a footer slot, say), you either accept multiple named props that hold JSX (headerSlot, footerSlot) or split the component into sub-components as in the Card example above.
Composition scales better than configuration because the component's own code never has to change to support a new layout — the consumer just nests different children. A configuration-based component, by contrast, needs a new prop (and new internal branching logic) for every variation a consumer asks for, and that prop list only ever grows.
Compound components and the provider pattern
A compound component is a group of components meant to be used together — <Tabs>, <Tabs.List>, <Tabs.Tab>, <Tabs.Panel> — that share implicit state behind the scenes instead of the consumer wiring that state between them by hand. The parent component (Tabs) creates a piece of state (which tab is active) and exposes it to its children through React Context; each child (Tabs.Tab, Tabs.Panel) reads that Context with useContext instead of receiving the active tab and a setter as explicit props.
This is the provider pattern applied at component scale: a Context.Provider wraps a subtree and makes some value available to any descendant that asks for it, without that value being threaded through every intermediate component's props. The provider pattern is not limited to compound components — a ThemeProvider, an AuthProvider, or a QueryClientProvider at the root of an app is the exact same pattern applied to app-wide concerns rather than one widget's internal state.
Designing a good compound component API means deciding which pieces are required, which are optional, and what happens if a sub-component (Tabs.Tab) is rendered outside its required parent (Tabs). A well-designed compound component throws a clear, descriptive error in that case rather than crashing with a confusing 'cannot read property of undefined'.
Render props and headless components
A render prop is a prop whose value is a function that returns JSX, used when a component wants to share stateful logic but let the caller fully control the rendered markup. Instead of <Toggle label="Dark mode" />, a render-prop version looks like <Toggle>{(on, toggle) => <button onClick={toggle}>{on ? 'On' : 'Off'}</button>}</Toggle> — the Toggle component owns the on/off state and the toggling logic, but exposes zero markup of its own, handing the caller everything needed to render however they want.
A headless component takes this idea to its logical extreme: it is a component (or, more often today, a custom hook) that implements all of a widget's behavior — state, keyboard handling, ARIA attributes, positioning math — and renders absolutely no markup or styling of its own. Libraries like Radix UI, Headless UI, and Downshift are built this way specifically so that teams with completely different design systems can share the same battle-tested interaction logic for a combobox, a dialog, or a menu.
The practical difference between a render-prop component and a headless hook is mostly API shape: <Toggle>{(on, toggle) => ...}</Toggle> renders through JSX children, while const { on, toggle } = useToggle() returns plain values you weave into your own JSX by hand. Modern React code leans heavily toward the hook style because it composes more naturally with other hooks and avoids the extra nesting render props introduce, but the underlying idea — separate behavior from presentation completely — is identical.
Controlled vs uncontrolled, custom hooks, and higher-order components as legacy
A controlled component's state lives entirely in the parent — a controlled <input value={value} onChange={e => setValue(e.target.value)} /> only shows what the parent's state says it should show. An uncontrolled component manages its own internal state and the parent only reads it when needed (e.g. via a ref). Most reusable form-adjacent components accept both modes: an internal useState as a fallback default, overridden by a value/onChange pair whenever the caller supplies one — this is the same 'controlled or uncontrolled' choice the native <input> element itself offers.
A custom hook — any function named useSomething that calls other hooks internally — is the modern, lightweight way to extract and reuse stateful logic across components, and it has quietly replaced most of what render props and higher-order components used to be needed for. If you find yourself writing a render-prop component whose only job is to expose some state and a couple of functions, a custom hook usually does the same job with less indirection and no extra JSX nesting.
Higher-order components (HOCs) — functions that take a component and return a new, wrapped component, such as withAuth(Dashboard) or connect(mapStateToProps)(MyComponent) from older Redux — were the primary way to share cross-cutting logic before hooks existed. They still appear in older codebases and a handful of libraries, so recognizing the pattern matters, but you should treat HOCs as a legacy pattern: they cause 'wrapper hell' (deeply nested component trees that are hard to read in DevTools), they make prop name collisions and prop-source tracing painful, and TypeScript typing for HOCs is notoriously awkward compared to a typed custom hook. New code should reach for a custom hook, composition, or the provider pattern instead of writing a new HOC.
A headless custom hook powering two different rendered components
useToggle knows nothing about buttons, checkboxes, or ARIA roles — that separation is exactly what makes it reusable across unrelated visual components. This is the modern equivalent of what a render-prop <Toggle> component or a withToggle() HOC would have provided a few years ago, with less nesting and simpler TypeScript types.
// useToggle.ts — all behavior, zero markup
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(v => !v), []);
return { on, toggle, setOn };
}
// Two completely different presentations reuse the same logic
function SwitchUI() {
const { on, toggle } = useToggle();
return <button role="switch" aria-checked={on} onClick={toggle}>{on ? "On" : "Off"}</button>;
}
function CheckboxUI() {
const { on, toggle } = useToggle();
return <input type="checkbox" checked={on} onChange={toggle} />;
}Code Examples
Tabs owns the active-tab state and provides it through Context; Tabs.Tab and Tabs.Panel each read it with useTabsContext instead of receiving it as an explicit prop, so the consumer can arrange them in any layout without manually wiring state between them. Expected result: Rendering <Tabs defaultActive="a"><Tabs.Tab id="a">A</Tabs.Tab><Tabs.Panel id="a">Content A</Tabs.Panel></Tabs> shows tab A selected and its panel visible; clicking a different Tab updates both automatically.
if (!ctx) throw new Error("Tabs.* components must be rendered inside <Tabs>");
Both versions share the exact same mouse-tracking logic and expose the same data, but the hook version avoids the function-as-children indirection and reads like ordinary component code — this is why most render-prop libraries from 2018-2019 have since grown a hook-based API.
Common mistake: Keeping a render-prop API purely for backward compatibility while writing all new internal logic as a hook underneath it is a common, reasonable transitional step — just don't build new render-prop-only components going forward.
type TabsCtx = { active: string; setActive: (id: string) => void };
const TabsContext = createContext<TabsCtx | undefined>(undefined);
function Tabs({ defaultActive, children }: { defaultActive: string; children: React.ReactNode }) {
const [active, setActive] = useState(defaultActive);
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs.* components must be rendered inside <Tabs>");
return ctx;
}
Tabs.Tab = function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { active, setActive } = useTabsContext();
return (
<button aria-selected={active === id} onClick={() => setActive(id)}>
{children}
</button>
);
};
Tabs.Panel = function Panel({ id, children }: { id: string; children: React.ReactNode }) {
const { active } = useTabsContext();
return active === id ? <div role="tabpanel">{children}</div> : null;
};// Older render-prop style
function MouseTracker({ children }: { children: (pos: { x: number; y: number }) => React.ReactNode }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return <>{children(pos)}</>;
}
// usage: <MouseTracker>{pos => <p>{pos.x}, {pos.y}</p>}</MouseTracker>
// Modern equivalent: a custom hook, no extra nesting
function useMousePosition() {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handler);
return () => window.removeEventListener("mousemove", handler);
}, []);
return pos;
}
// usage: const pos = useMousePosition(); <p>{pos.x}, {pos.y}</p>Where each pattern sits between 'behavior' and 'markup'
Composition, compound components, render props, and headless hooks all trade some rendering convenience for reuse flexibility — the further left, the more the caller controls the visuals.
Fully headless Behavior + some markup Fully coupled
(hook only) <-----------------------------> (giant single component)
useToggle() <Toggle>{render}</Toggle> <ToggleSwitch label="..." color="..." />
no JSX at all caller supplies JSX component supplies all JSXImportant Rules
Prefer composition (children, named slot props, compound components) before reaching for configuration props that keep multiplying.
Use the provider pattern (Context.Provider) for state that a whole subtree needs, not for state only one or two components use — that just adds indirection.
Throw a clear error when a compound sub-component is used outside its required parent's Context, instead of letting it fail with a silent 'undefined' crash.
Reach for a custom hook first when you need to share stateful logic; use a render prop only when the caller genuinely needs to control the rendered JSX and a hook return value would not be enough.
Treat higher-order components as a legacy pattern to recognize, not to author — new cross-cutting logic belongs in a custom hook or the provider pattern.
Where these patterns show up
Nearly every serious component library uses these patterns by name: Radix UI and Headless UI ship fully headless primitives for dialogs, menus, and comboboxes; React Router's <Routes>/<Route> and Formik's <Field> historically used render props before growing hook equivalents (useRoutes, useField); and design systems like Chakra UI and Reach UI use compound components (<Accordion><AccordionItem>) so teams can restyle and rearrange widgets without forking the library's internals.
function withAuth(Component: React.ComponentType) { return function Wrapped(props: any) { const user = useCurrentUser(); if (!user) return <LoginPage />; return <Component {...props} user={user} />; }; } const ProtectedDashboard = withAuth(Dashboard);
function useRequireAuth() { const user = useCurrentUser(); return user; // caller decides what to render when it's null } function Dashboard() { const user = useRequireAuth(); if (!user) return <LoginPage />; return <DashboardBody user={user} />; }
The HOC version silently injects a user prop with an any type, makes the wrapped component's real props hard to trace in DevTools (it shows up as Wrapped(Dashboard)), and is awkward to type correctly in TypeScript. A custom hook gives the same behavior with explicit, fully-typed data flow and no extra component in the tree.
[multiple-choice] Why is composition (nesting children) generally preferred over adding more configuration props to a single component? A. Configuration props are slower to render than children B. Composition avoids an ever-growing prop list and lets consumers rearrange markup without changing the component's source C. Children props are required by the JSX specification D. Configuration props cannot hold event handlers
B. Composition avoids an ever-growing prop list and lets consumers rearrange markup without changing the component's source — Composition lets consumers customize layout by nesting different children, so the component's own code doesn't need a new prop and new branching logic for every layout variation a consumer wants.
[multiple-choice] What should a well-designed compound sub-component (like Tabs.Tab) do if it is rendered outside its required parent (Tabs)? A. Silently render nothing with no indication of the problem B. Crash with a generic 'cannot read property of undefined' error C. Throw a clear, descriptive error explaining it must be used inside the parent D. Automatically create its own default Context value
C. Throw a clear, descriptive error explaining it must be used inside the parent — Checking for a missing Context value and throwing a clear, specific error message is far easier to debug than letting the code crash later with a confusing generic error.
[multiple-choice] Why are higher-order components (HOCs) now considered a legacy pattern in React? A. React no longer supports functions that return components B. Custom hooks provide the same logic-sharing benefit with better TypeScript typing and without wrapper-component nesting ('wrapper hell') C. HOCs were removed from the React API in a recent version D. HOCs can only be used with class components
B. Custom hooks provide the same logic-sharing benefit with better TypeScript typing and without wrapper-component nesting ('wrapper hell') — Hooks let you share stateful logic across components without wrapping them in extra component layers, which avoids wrapper hell, prop-name collisions, and the awkward generic types HOCs require in TypeScript.
Sketch (in comments or pseudocode) a compound Accordion made of <Accordion>, <Accordion.Item>, and <Accordion.Trigger>, sharing an openId state via Context the same way the Tabs example does, and identify which parts of your design are 'headless' (no imposed styling) versus which impose a specific DOM structure.
// AccordionContext = createContext<{ openId: string | null; setOpenId: (id: string) => void }>() // Accordion: const [openId, setOpenId] = useState<string | null>(null); provides { openId, setOpenId } // Accordion.Trigger: const { setOpenId } = useAccordionContext(); onClick={() => setOpenId(id)} // Accordion.Item: const { openId } = useAccordionContext(); return openId === id ? children : null; // This is headless in the sense that no CSS classes or DOM tag choices are imposed — the consumer picks the wrapping elements and styling; only the open/closed *behavior* is fixed.
Accordion creates and provides { openId, setOpenId }; Accordion.Trigger reads it and calls setOpenId(id) on click; Accordion.Item conditionally renders its children when openId === its own id.