Compound Components

Component Design// 5 min read

Split a monolithic component into cooperating children that share implicit state, giving consumers layout control without exposing internals.

The Problem

A <Select> component that accepts options, value, onChange, renderOption, renderTrigger, placeholder, disabled, searchable, maxHeight, virtualized, groupBy, sortFn, emptyMessage, loadingMessage... The prop list grows until the component is a configuration language, not a component.

Every new feature is a new prop. Every new prop is a new conditional branch in the render function. The component becomes a ball of mud that nobody wants to touch.

The deeper problem: the consumer has no control over layout. Want the options above the trigger instead of below? Add a placement prop. Want a divider between groups? Add a showDividers prop. Want a footer with a “Create new” button? Add a footer prop. Each is reasonable in isolation. Together they encode a parallel layout language inside the component's props.

The Principle

A component should be split into composable sub-components when its prop list starts encoding layout decisions or rendering variations.

The compound component pattern replaces configuration props with composition. Instead of <Select options={...} renderOption={...} footer={...}>, you write:

TS
1<Select value={value} onValueChange={setValue}>
2 <Select.Trigger>
3 <Select.Value placeholder="Choose..." />
4 </Select.Trigger>
5 <Select.Content>
6 <Select.Group>
7 <Select.Label>Fruits</Select.Label>
8 <Select.Item value="apple">Apple</Select.Item>
9 <Select.Item value="banana">Banana</Select.Item>
10 </Select.Group>
11 <Select.Separator />
12 <Select.Footer>
13 <button>Create new...</button>
14 </Select.Footer>
15 </Select.Content>
16</Select>

The sub-components share state through context (the selected value, the open/closed state, keyboard navigation position). The consumer controls layout through composition — put the Footer wherever you want, or omit it entirely. New features don't require new props on the root; they require new sub-components, which can be composed independently.

This is inversion of control applied to React components. The library doesn't render for you; it gives you building blocks that handle the hard parts (state, accessibility, keyboard interactions) and lets you control the easy parts (layout, styling, additional content).

Prop Explosion

Monolithic
<Select
value={selected}
onChange={setSelected}
options={items}
placeholder="Choose..."
/>
Compound
<Select value={selected} onChange={set}>
<Select.Item value="apple">
</Select.Item>
<Select.Item value="banana">
</Select.Item>
</Select>
Props:4
Sub-components:3

Real-World Example

Radix UI's Select component demonstrates this pattern cleanly. Here's an annotated excerpt from their source:

TS
1// The root provider shares state across all sub-components
2function Select({ value, onValueChange, children }) {
3 const [open, setOpen] = useState(false);
4
5 return (
6 <SelectContext.Provider value={{ value, onValueChange, open, setOpen }}>
7 {children}
8 </SelectContext.Provider>
9 );
10}
11
12// Each sub-component reads only what it needs from context
13function SelectTrigger({ children }) {
14 const { open, setOpen } = useSelectContext();
15 return (
16 <button
17 aria-expanded={open}
18 onClick={() => setOpen(!open)}
19 >
20 {children}
21 </button>
22 );
23}
24
25// Items know their own selected state
26function SelectItem({ value, children }) {
27 const { value: selected, onValueChange } = useSelectContext();
28 return (
29 <div
30 role="option"
31 aria-selected={value === selected}
32 onClick={() => onValueChange(value)}
33 >
34 {children}
35 </div>
36 );
37}

Notice how each sub-component has a single concern. SelectTrigger manages open/close. SelectItem manages selection. Neither knows about the other's existence. The consumer composes them in whatever order and layout they want.

Anti-Patterns

The Render Prop Graveyard

TS
1<Select
2 renderTrigger={({ open }) => <MyTrigger open={open} />}
3 renderOption={({ item, selected }) => <MyOption item={item} />}
4 renderGroup={({ label, children }) => <MyGroup label={label}>{children}</MyGroup>}
5 renderEmpty={() => <EmptyState />}
6/>

Every visual customization point is a render prop. This is compound components without the composition — the consumer still can't control layout, they can only replace specific render slots. And the parent component still orchestrates where each render prop appears.

The Premature Split

Splitting a 40-line component into 8 sub-components when it's used in exactly one place with no variations. Compound components solve the variability problem. If there's no variability, the indirection is pure cost. A simple component used once doesn't need decomposition — it needs to stay simple.

The Context Leak

Sub-components that work outside their parent context because the context has a default value. <Select.Item> rendered without a <Select> parent silently renders as a div that does nothing. The compound contract should enforce co-location — throw in development if context is missing.

Related Principles

  • Primitive Composition — Compound components are one instantiation of the broader “compose small pieces” principle. Primitive Composition is the general principle; Compound Components is the React-specific pattern.
  • Render Delegation — When compound sub-components use asChild or render props to let the consumer control the underlying DOM element, that's render delegation layered on top of composition.

When to Break This Rule

  • One-off components with no variation. A dashboard chart used exactly once, with exactly one layout, doesn't benefit from decomposition. The monolith is simpler.
  • Performance-critical components where context overhead matters. Each context read is a potential re-render boundary. For components rendered thousands of times (virtualized list items), the context lookup overhead may matter. Profile first.
  • When the compound API is more complex than the prop API. If your component has 3 features and the compound version requires 6 sub-components with specific nesting rules, the cure is worse than the disease. Compound components reduce complexity only when the feature count is high enough that prop-based configuration becomes unwieldy.

Deep-Dive References

  • From Bespoke to Semantic, Part 2 — The specific refactoring journey where <FlowDiagram> was split into composable sub-components, showing the before/after of prop explosion vs. composition.

Related Principles