Composition patterns: compound components over boolean props
Boolean props explode into a 2^n matrix nobody tests. Composition moves variation into JSX: compound components share state via context with dev-time guards and controlled/uncontrolled duality; children inspection is fragile; render props win when the library owns the loop.
The design-system Button started life with two props: variant and size. Three years and four product teams later it had 23 — loading, loadingText, leftIcon, rightIcon, iconOnly, fullWidth, quiet, destructive, uppercase, asLink, href, tooltip, and on. Each one was someone’s deadline: a PM needed a spinner, a checkout flow needed a danger style, marketing needed all-caps. Each addition looked cheap in review — one prop, one conditional. Then a release broke production checkout: loading together with iconOnly rendered the spinner on top of the icon, because nobody had ever rendered that pair. Nobody could have: nine boolean props is 512 render states, and the snapshot suite covered 14 of them. The team froze the component and audited usages — 40% of call sites passed prop combinations the original authors never imagined, several of them visually broken for months without a bug report. Meanwhile the same system’s Tabs component, built as a compound family, had absorbed three years of new requirements — icons in triggers, badges, lazy panels — with zero new props on Tabs itself, because variation lived in JSX at the call site. The difference was not talent. It was where each API put change.
The configuration–composition spectrum
Every component API sits on a spectrum. At one end, configuration: the caller passes props, the component owns all structure. At the other, composition: the caller passes JSX, the component owns only the frame around it. Configuration is closed and enumerable — variant="primary" | "ghost" | "danger" is a typed, finite contract you can exhaustively test and document. Composition is open — children admits anything, which is exactly the point.
The failure mode of configuration is the boolean-prop explosion. Each is*/has* boolean doubles the component’s state space: 9 booleans means 2⁹ = 512 combinations, and the interactions between them (loading × iconOnly, quiet × destructive) are real render states your tests almost certainly skip. Worse, boolean props are a one-way street: removing one is a breaking change across every consumer, so the matrix only grows. The reliable smell is a prop that describes contents rather than behavior — leftIcon, subtitle, footerActions, loadingText. Contents belong to the caller; the moment you find yourself adding a prop to inject markup, the API wants children or a slot:
// Year three of the Button. Every prop was someone's deadline.
<Button
variant="primary" size="md" loading loadingText="Saving…"
leftIcon={<SaveIcon />} fullWidth uppercase tooltip="Save the draft"
/>
// Composition: the same needs, zero new Button props.
<Button variant="primary" size="md" disabled={saving}>
{saving ? <Spinner label="Saving…" /> : <SaveIcon />}
Save draft
</Button>The rule senior reviewers apply: enumerable, constrained variation stays a prop (it is a design decision the system owns — variant, size, tone); arbitrary structure becomes children or slots (it is the caller’s content, and the system should not enumerate it).
A design-system Button has 14 props, 9 of them boolean. Product asks for a dropdown arrow on the right, for a split-button case. What is the API move that stops the matrix from growing?
Compound components: a context-wired family
When several parts must share state — Tabs and its triggers and panels, Select and its options, Accordion and its items — the composition answer is a compound component: a family of components that communicate through an implicit React context, so the caller composes the parts freely while the family coordinates behind the scenes. The shape every serious system converges on:
const TabsCtx = createContext<TabsContextValue | null>(null);
function useTabs(part: string) {
const ctx = useContext(TabsCtx);
if (ctx === null) {
// Fail loudly at development time, at the exact misuse site.
throw new Error(part + " must be rendered inside <Tabs>");
}
return ctx;
}
export function Tabs({ value, defaultValue, onValueChange, children }: TabsProps) {
const [internal, setInternal] = useState(defaultValue ?? null);
const isControlled = value !== undefined;
const active = isControlled ? value : internal;
const select = useCallback((next: string) => {
if (!isControlled) setInternal(next); // uncontrolled: own the state
onValueChange?.(next); // controlled: the parent decides
}, [isControlled, onValueChange]);
const ctx = useMemo(() => ({ active, select }), [active, select]);
return <TabsCtx.Provider value={ctx}>{children}</TabsCtx.Provider>;
}
Tabs.Trigger = function Trigger({ value, children }: TriggerProps) {
const { active, select } = useTabs("<Tabs.Trigger>");
return (
<button role="tab" aria-selected={active === value} onClick={() => select(value)}>
{children}
</button>
);
};Three load-bearing details. First, the context default is null and useTabs throws on it — a Tabs.Panel pasted outside Tabs fails at the misuse site with a named error, instead of rendering broken UI or crashing on an undefined read three layers deeper. Second, the controlled/uncontrolled duality: the component supports both value (parent owns state, component merely reports intent through onValueChange) and defaultValue (component owns state internally). isControlled is decided by value !== undefined and must stay fixed for the life of the instance — a half-controlled component that updates internal state and reads value will visually desync the moment the parent skips an update. Third, the context value is memoized: every consumer of TabsCtx re-renders when the value changes identity, so an inline object here would re-render every trigger and panel on each Tabs render.
Slots versus children inspection
There is an older way to wire a family: iterate children, find your parts by element type, and inject props with cloneElement. It reads cleverly and it is a trap:
// Children inspection couples the parent to the exact element tree.
function Tabs({ children }) {
return Children.map(children, (child, i) =>
isValidElement(child) && child.type === Tab
? cloneElement(child, { index: i })
: child // a Fragment, a FeatureFlag wrapper, a custom Tab — silently skipped
);
}Children.map sees the immediate element layer, nothing deeper. Wrap a Tab in a fragment and the fragment is one opaque child; the tabs inside it are invisible. Wrap it in a FeatureFlag component and child.type is FeatureFlag, not Tab — the match fails, the index never arrives. Extract two tabs into a local BillingTabs component — same silent breakage, because what a component will render does not exist until render. Every one of these is a normal, encouraged refactor, and children inspection punishes all of them. That is why react.dev files the Children API under legacy and why context-wired compounds won: context reaches any descendant at any depth, through any wrapper, with no assumptions about tree shape. For fixed regions where you want explicit structure — an icon, a set of actions — a named slot is the honest tool: a plain prop typed ReactNode (icon={<Save />}, actions={...}), enumerable like configuration but open like composition.
Tabs is implemented with Children.map, matching child.type === Tab to inject an index. A teammate wraps one Tab in a FeatureFlag component and that tab silently disappears. Why?
Where render props still win
Hooks replaced render props for logic sharing, but one niche is structurally theirs: the component owns a loop over data and the app owns the markup per item. A virtualized list decides which rows exist, measures them, and positions them — only it knows the row data and the absolute-positioning style for each visible index. It must hand both back to the caller, per row, and a function child is the only channel with that shape:
<VirtualList items={rows} rowHeight={32}>
{(row, style) => (
<div style={style} className="row">
{row.symbol} · {row.qty}
</div>
)}
</VirtualList>Plain children cannot do this — children are created by the caller before the list knows anything. The test is direction of data flow: when the parent produces per-invocation data the caller needs, a render prop; when the caller produces the content, children; when parts share state, a compound family. TanStack Virtual and Table, downshift, and every serious data-grid live in this niche on purpose.
▸Why this works
Why did context beat cloneElement for compound wiring when cloneElement is simpler to write? Because the two differ in what they assume. cloneElement-based inspection assumes the element tree at the parent’s level is the logical structure — an assumption every refactor (fragments, wrappers, extraction) breaks, silently, with no error pointing at the cause. Context assumes only that the part renders somewhere below the provider, which is the one invariant refactors preserve. The cost of context — a provider, a guard hook, memoizing the value — is paid once by the system author; the cost of inspection is paid forever by every consumer who refactors. Design-system APIs are consumed thousands of times and authored once, so the trade always lands the same way. The react.dev docs reflect the verdict: Children and cloneElement sit in the legacy section with explicit alternatives listed.
- 01Walk the decision procedure for extending a design-system component API, and name the failure mode each branch avoids.
- 02How does a compound component share state, why is context strictly more robust than Children inspection, and what is the controlled/uncontrolled duality?
Component APIs live on a configuration–composition spectrum, and the senior skill is placing each piece of variation on the right end. Configuration — typed props — is for closed, enumerable decisions the design system owns: variant, size, tone. Its failure mode is the boolean explosion: every is-or-has prop doubles the state space, nine booleans is 512 render states with maybe a dozen tested, the combinations interact visually, and props are one-way — removal breaks consumers, so the matrix only compounds. The reliable smell is a prop describing contents rather than behavior; contents belong to the caller, as children or a named ReactNode slot. When several parts must coordinate, the answer is a compound component family: a context with a null default, a guard hook that throws a named error at the misuse site, a memoized context value so consumers do not re-render on every parent render, and the controlled/uncontrolled duality — value plus onValueChange when the parent owns state, defaultValue when the component does, with the controlled-ness decided once by value !== undefined and never flipped mid-life. The older wiring, Children.map with cloneElement, couples the parent to the exact element tree: fragments, wrapper components, and extracted subcomponents all break the type match silently, which is exactly why it lives in the legacy section of the docs and why context won. And one pattern survives the hooks era untouched: when a component owns a loop over data — virtualized lists, data grids — and must hand each caller per-item data and positioning, a render prop is the only channel with the right shape. Choose by direction of data flow, and the API stops growing props. Now when you see a PR adding the 10th boolean to a component, you know to ask: is this a design decision the system owns, or content that belongs to the caller — and you know which direction to push it.
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.