Compound Components: Designing an API, Not Just a Component

You build a <Select>. It starts clean — options in, value out. Then the requests arrive. Someone needs a divider between groups. Someone needs an icon next to certain options. Someone needs a sticky "create new" row at the bottom. Someone needs a disabled option with a tooltip explaining why. Each request adds a prop: groups, renderIcon, footerSlot, disabledReason. Six months later your <Select> takes twenty-three props, half of them undocumented, and every new requirement means editing the component and praying you didn't break the other twenty-two use-cases.
This is the prop-explosion death spiral, and it happens to every successful shared component. The problem isn't that you added props badly. It's that props were the wrong tool for the job somewhere around request number three. What you actually needed was to stop configuring a component and start composing one. That's what compound components are.
The last few articles have been about boundaries and where code lives. This one starts a run on the patterns you build inside those boundaries — the techniques that decide whether the components you write get reused or quietly rewritten. Compound components are the first one because they're the most common thing people get wrong.
The wall you hit with props
Props are perfect right up until the caller needs control over structure. A boolean, a string, a callback — props handle those beautifully. But the moment the caller wants to decide what goes where — the order of things, what sits between them, what wraps them — props turn into a configuration language you're now forced to invent and maintain.
// the prop-explosion version — every layout decision is a prop <Select options={options} value={value} onChange={setValue} renderOption={(o) => <Icon name={o.icon} />} groupBy="category" groupHeaderRenderer={(g) => <strong>{g}</strong>} footer={<CreateNewRow />} showDividers dividerAfter={[2, 5]} />
Look at that dividerAfter={[2, 5]}. That's the smell. You're passing array indices to describe where a line should appear, because the component owns the structure and the caller has to negotiate with it through props. Every new layout wish is a new prop, a new branch inside the component, and a new way for the component to break. The component knows too much about every caller, and every caller knows too much about the component's internal prop vocabulary.
When callers start passing you layout instructions as props, they're asking for composition. Give it to them.
Compound components invert the control
A compound component flips the relationship. Instead of one component that takes a pile of props describing structure, you expose a set of components that the caller arranges themselves. The classic shape everyone knows is the native <select>:
<select> <option>One</option> <optgroup label="More"> <option>Two</option> </optgroup> </select>
You don't pass <select> a groups prop. You compose <option> and <optgroup> inside it. The React version of that idea looks like this:
<Select value={value} onChange={setValue}> <Select.Option value="one">One</Select.Option> <Select.Divider /> <Select.Group label="More"> <Select.Option value="two" icon={<StarIcon />}>Two</Select.Option> </Select.Group> <Select.Footer><CreateNewRow /></Select.Footer> </Select>
Every one of those twenty-three props just became a component the caller places wherever they want. Need a divider after the second item? Put a <Select.Divider /> there. Need an icon? Pass it to that one <Select.Option>. The component stopped owning the structure, and the caller took it back — which is exactly what they were trying to do with all those props. You didn't add flexibility by adding configuration. You added it by removing configuration and letting composition do the work.
The part that makes it actually work: shared state
Here's the question that trips people up: if <Select.Option> is a separate component sitting who-knows-how-deep inside the tree, how does clicking it tell <Select> about the new value? You can't pass props down — you don't own the JSX in between. The answer is React Context, and this is the one piece of machinery that turns a pile of dotted components into a real compound component.
const SelectContext = createContext<{ value: string; onChange: (v: string) => void; } | null>(null); function Select({ value, onChange, children }: SelectProps) { return ( <SelectContext.Provider value={{ value, onChange }}> <div role="listbox">{children}</div> </SelectContext.Provider> ); } function Option({ value, icon, children }: OptionProps) { const ctx = useContext(SelectContext); if (!ctx) throw new Error('<Select.Option> must be used inside <Select>'); const selected = ctx.value === value; return ( <div role="option" aria-selected={selected} onClick={() => ctx.onChange(value)} className={selected ? 'bg-[#22c55e]/10' : ''} > {icon} {children} </div> ); } // attach the children as properties — this is what enables the dot syntax Select.Option = Option; Select.Divider = () => <div role="separator" className="my-1 border-t border-gray-200" />; Select.Group = Group; Select.Footer = Footer;
The parent puts the shared state into context. Every child reads from context, so it doesn't matter how deeply it's nested or what the caller wraps it in — an <Option> three divs down still knows the current value and can report a click. That context is the invisible wire connecting components the caller arranged. Props flow explicitly to configure one option (its icon, its value); context flows implicitly to coordinate all of them. That division is the whole trick.
Notice the throw in Option. Because these components only make sense together, a compound component should fail loudly when used apart. A bare <Select.Option> with no <Select> around it is a bug, and catching it with a clear error is far kinder than letting it silently render nothing.
When it earns its place — and when it doesn't
Compound components are not free. They're more code, more indirection, and a context that has to exist. So I don't reach for them by default. I reach for them when three signals show up together.
The caller needs to control structure. If people keep asking to reorder parts, inject things between parts, or wrap parts — that's the tell. Structure control is what props are bad at and composition is good at.
The parts are meaningless alone but meaningful together. Tabs/TabList/Tab/TabPanel. Accordion/AccordionItem. Menu/MenuItem/MenuSeparator. None of those children make sense on their own, and all of them need to coordinate through shared state. That's the exact shape compound components fit.
There's real shared state to coordinate. If the children don't need to know about each other or a common value, you don't need the context, and you probably don't need this pattern — you just need components that accept children.
And when not to: a component with two or three stable props and no structural variation is perfect as a plain component. Wrapping a <Button> in a compound API is pure ceremony. The pattern earns its complexity only when the alternative is prop explosion. If you're not drowning in configuration props, you don't have the problem this solves.
The mental shift that matters
The reason I wanted to open the patterns run with this one is that compound components force a change in how you think, and that change outlasts the specific technique. The first time your component grows past a handful of props, you're not building a component anymore. You're designing an API that other people — including future you — will program against. And APIs are judged by how gracefully they handle the requests you didn't anticipate.
A prop-based component handles the unanticipated request by growing a new prop, and eventually collapses under its own configuration surface. A compound component handles it by letting the caller compose a solution you never had to predict — the divider you didn't plan for, the footer you didn't imagine — without you touching the component at all. That's the difference between a component people configure and a component people build with. The first one you maintain forever. The second one mostly maintains itself.
Next I want to push this one step further, to the pattern that takes the same idea and removes the last assumption a compound component still makes — that you own the markup at all. Headless components, render props, hooks-as-API: handing over behavior while giving up the UI entirely. If you've got a shared component in your codebase with more than a dozen props, go count them, then ask how many are really the caller trying to control structure. I'd bet the number is higher than you'd like — tell me what you find.
Found this helpful?
Subscribe on Telegram for new posts, or reach out to discuss your project.