The compound component pattern
Compound components are a parent plus sub-components sharing implicit state via context: the parent owns behavior, the consumer arranges the pieces. They pay off for rearrangeable design-system widgets, and are pure overhead for a fixed two-element component.
You’re building a Tabs widget for a design system. The naive API takes a tabs array and a renderPanel callback, and every team that adopts it immediately asks for the one thing it can’t do: “can I put a badge next to the second tab label?”, “can the panels render before the tab strip on mobile?”, “can I drop a tooltip between two tabs?”. Each request becomes a new prop, and the component drifts toward a config language no one enjoys.
The compound component pattern flips the ownership. The parent keeps the behavior — which tab is active, how arrow keys move focus, the ARIA wiring — and hands the layout back to the consumer as ordinary JSX they arrange however they want. Tabs, Tabs.List, Tabs.Tab, Tabs.Panel talk to each other through implicit shared state, and the caller composes them like HTML.
After this lesson you can build a compound component — a parent that owns state plus sub-components that read it through context — and expose it as Parent.Child sub-fields; explain why this trades a richer API surface for consumer layout freedom; recognize that it shines for reusable design-system widgets whose internals get rearranged per screen; and name its failure mode — a fixed two-element component that will never be rearranged, where a plain props component is strictly better.
A compound component is one parent that owns state plus sub-components that read it implicitly — the consumer never wires the state by hand. Compare the two API shapes. The props-driven Tabs accepts a data array and dictates the markup; the compound Tabs accepts children and lets the caller write the markup, while the pieces silently share which tab is active.
// props-driven: the component owns the layout, you feed it config
<Tabs
tabs={[{ id: "a", label: "Account", panel: <Account /> }]}
renderTab={(t) => <span>{t.label}</span>}
/>
// compound: you own the layout, the pieces share state implicitly
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Tab value="account">Account</Tabs.Tab>
<Tabs.Tab value="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="account"><Account /></Tabs.Panel>
<Tabs.Panel value="billing"><Billing /></Tabs.Panel>
</Tabs>The compound version reads like markup, not configuration. That readability is the feature — and it comes from the pieces sharing state without the caller passing activeTab to each one.
The implicit channel between parent and pieces is React context — the parent provides, every sub-component consumes. The parent holds the active value in state and exposes it (plus a setter) through a context. Tabs.Tab reads the context to know whether it’s selected and to flip the value on click; Tabs.Panel reads it to decide whether to render. No prop drilling, no caller-supplied glue.
type TabsCtx = { value: string; setValue: (v: string) => void };
const TabsContext = createContext<TabsCtx | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tabs.* must be rendered inside <Tabs>");
return ctx;
}
function Tabs({ defaultValue, children }: { defaultValue: string; children: React.ReactNode }) {
const [value, setValue] = useState(defaultValue);
return <TabsContext.Provider value={{ value, setValue }}>{children}</TabsContext.Provider>;
}The useTabs guard that throws when used outside the provider is not optional polish — it turns a confusing null crash into a clear contract violation at the call site.
The sub-components are attached as static fields on the parent — Tabs.List, Tabs.Tab, Tabs.Panel — so the API arrives as a single import. Each piece is a normal component that calls useTabs(). Hanging them off the parent groups the family namespace, signals they belong together, and means consumers import one symbol.
Tabs.List = function List({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>;
};
Tabs.Tab = function Tab({ value, children }: { value: string; children: React.ReactNode }) {
const { value: active, setValue } = useTabs();
const selected = active === value;
return (
<button role="tab" aria-selected={selected} onClick={() => setValue(value)}>
{children}
</button>
);
};
Tabs.Panel = function Panel({ value, children }: { value: string; children: React.ReactNode }) {
const { value: active } = useTabs();
return active === value ? <div role="tabpanel">{children}</div> : null;
};Tabs.Tab doesn’t receive isActive from the consumer — it derives it from shared state. That derivation living inside the piece is what gives the caller layout freedom: they place the tab anywhere, and it still knows whether it’s selected.
The payoff is consumer ergonomics: the caller rearranges the internals freely because the pieces find their state by position in the tree, not by prop wiring. The same Tabs renders panels-above-list on mobile, slips a badge into a tab label, or drops a divider between tabs — none of which the parent had to anticipate. A props-driven API would need a new prop for each of those; the compound API needs nothing, because layout is the consumer’s job and behavior is the parent’s.
// the consumer reshuffles internals — the parent never changed
<Tabs defaultValue="inbox">
<Tabs.Panel value="inbox"><Inbox /></Tabs.Panel> {/* panels first on mobile */}
<Tabs.List>
<Tabs.Tab value="inbox">Inbox <UnreadBadge /></Tabs.Tab> {/* badge inline */}
<Divider /> {/* arbitrary node */}
<Tabs.Tab value="archive">Archive</Tabs.Tab>
</Tabs.List>
</Tabs>This is exactly why design systems (Radix, Reach, Headless UI, Ariakit) ship compound APIs: the library owns the hard, invariant behavior; the application owns the layout that differs per screen.
From a config-heavy props API to a compound one — and the moment it stops being worth it. A team ships an Accordion as a props-driven component:
// before: every layout need becomes a prop
<Accordion
items={[{ id: "1", title: "Shipping", body: <Shipping /> }]}
renderTitle={(item, open) => <span>{item.title} {open ? "▲" : "▼"}</span>}
allowMultiple
defaultOpenIds={["1"]}
/>Three screens later the props list has grown renderIcon, titleClassName, headerSlot, dividerBetweenItems — each added because one screen needed to arrange the internals differently. That prop-creep is the signal to go compound:
// after: the parent owns open-state; the consumer arranges everything else
<Accordion defaultOpen={["shipping"]} allowMultiple>
<Accordion.Item value="shipping">
<Accordion.Header>Shipping <Chevron /></Accordion.Header>
<Accordion.Body><Shipping /></Accordion.Body>
</Accordion.Item>
<Divider />
<Accordion.Item value="returns">
<Accordion.Header>Returns</Accordion.Header>
<Accordion.Body><Returns /></Accordion.Body>
</Accordion.Item>
</Accordion>Accordion still owns which items are open and the toggle/ARIA behavior; the header markup, the chevron, the divider, the per-screen tweaks are now ordinary JSX. The props API would have needed a new prop for each; the compound API absorbs them for free.
Now the failure mode. Suppose instead of an accordion you have a Stat card that shows a label above a value — two fixed elements, always in that order, never rearranged:
// over-engineered: a compound API for a fixed two-element component
<Stat>
<Stat.Label>Revenue</Stat.Label>
<Stat.Value>$2.4M</Stat.Value>
</Stat>
// right-sized: a plain props component, half the code, nothing to misuse
<Stat label="Revenue" value="$2.4M" />The compound Stat buys nothing — there is no layout to rearrange, no behavior to share, no second arrangement coming — and it costs a context, two sub-components, and a more verbose call site that a reader has to assemble in their head. When the internals are fixed and there’s no behavior to centralize, the plain props component is the senior choice. Compound components earn their cost only when the consumer genuinely needs to arrange the pieces.
▸Why this works
Why context rather than React.Children.map + cloneElement to inject the active state into each child? cloneElement only reaches direct children, so the moment a consumer wraps a Tabs.Tab in their own <div> or a <Divider> sits between tabs, the injection misses it. Context reaches any descendant at any depth, which is precisely the layout freedom the pattern promises. cloneElement-based compound components are a known footgun for exactly this reason; context is the modern default.
▸Common mistake
The seductive misread is “compound components are the advanced API, so reach for them in the design system by default”. They earn their keep only under rearrangement pressure — the consumer needs to place, reorder, or interleave the internals. A fixed-shape widget (a labeled value, an avatar-plus-name chip, an icon button) has no internals worth exposing, so a compound API there is pure overhead: more code, a context re-render path, and a call site a reader must reassemble for zero flexibility gained. Match the pattern to the pressure: rearrangeable internals → compound; fixed two-element shape → plain props.
You're designing a reusable Avatar component for the design system. It always renders an image with a status dot in the bottom-right corner — a fixed two-element shape, never rearranged, used the same way everywhere. Should you expose it as a compound component (Avatar + Avatar.Image + Avatar.StatusDot)?
A compound component is a parent that owns state plus sub-components that read that state implicitly through context, attached as static fields (Tabs.List, Tabs.Tab, Tabs.Panel) so the API arrives as one import. The split is the point: the parent owns behavior (active value, focus, ARIA), the consumer owns layout by arranging the pieces as ordinary JSX. Because each piece finds its state by position in the tree rather than by prop wiring, the caller can reorder, interleave, and decorate the internals with nothing the parent had to anticipate — which is exactly why design systems expose their widgets this way. The cost is a richer surface (a context, sub-components, a guard for misuse), so it pays off only under rearrangement pressure. The failure mode is using it for a fixed two-element component that will never be rearranged: there a plain props component is strictly better — less code, nothing to misassemble, and no flexibility lost. Rearrangeable internals → compound; fixed shape → plain props.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.