AcademyReact
Compound Components
How Context replaces a giant prop API with a group of components that implicitly share state through composition.
3 min readUpdated 3 exercises
The prop-API problem
A <Select> that supports icons, disabled options, grouping and custom rendering ends up with a prop for each of those: options, renderOption, getOptionDisabled, groupBy, onSelect, value... Every new requirement is another prop, and the component's API surface grows without bound while the caller loses the ability to just write markup.
Compound components solve this by splitting the one big component into several small ones that are meant to be used together — <Select> and <Select.Option> — and that implicitly share state through composition instead of the parent threading every value down as a prop.
<Select value={selected} onChange={setSelected}>
<Select.Option value="js">JavaScript</Select.Option>
<Select.Option value="ts">TypeScript</Select.Option>
<Select.Option value="py" disabled>Python (soon)</Select.Option>
</Select>Nothing here tells <Select.Option> which value is currently selected, or gives it a way to report a click back up. That connection is implicit — supplied by the parent through context, not passed explicitly at each call site.
Implementing it
The parent renders a Context Provider holding the shared state; each child reads it with useContext. Here is the whole pattern on a minimal Tabs component:
const TabsContext = createContext(null);
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue);
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.Tab must be rendered inside <Tabs>');
return ctx;
}
function Tab({ value, children }) {
const { active, setActive } = useTabsContext();
return (
<button
className={value === active ? 'tab tab-active' : 'tab'}
onClick={() => setActive(value)}
>
{children}
</button>
);
}
Tabs.Tab = Tab;Tab never receives active or setActive as props. It reaches into context and gets both, because it was rendered somewhere inside a <Tabs>. Tabs.Tab = Tab is what makes <Tabs.Tab> valid JSX — attaching the child as a static property of the parent, a convention (not a language feature) that signals "these two are meant to be used together."
What this buys you
The consumer now controls composition, not just configuration. They can reorder tabs, wrap one in a conditional, add a badge next to a specific tab's label, or omit one entirely — all by editing markup, with zero new props on <Tabs> itself:
<Tabs defaultValue="js">
<Tabs.Tab value="js">JavaScript</Tabs.Tab>
{showPython && <Tabs.Tab value="py">Python</Tabs.Tab>}
<span className="badge">New</span>
<Tabs.Tab value="ts">TypeScript</Tabs.Tab>
</Tabs>A single monolithic <Tabs items={[...]} renderItem={...}> cannot offer this without inventing a render-prop escape hatch for every point of customization someone eventually asks for. Compound components get composition for free because they are just components, arranged as JSX — the tool React gives you for composition in the first place.
What it costs
The coupling is implicit and only enforced at runtime. <Tabs.Tab> rendered outside a <Tabs> compiles fine and fails the moment it runs — useContext(TabsContext) returns null, and without the guard in useTabsContext above, the failure is a confusing crash deep inside the child rather than a clear message at the call site. TypeScript does not catch a misplaced <Tabs.Tab> either; the throw in useTabsContext is the whole safety net, and it only fires once the code actually runs.
Now practice it
2 challenges · 1 quizReading this page is the cheap half. These are the exercises that make you use it.
- Compound ComponentsProBuilds the Provider-and-static-property wiring end to end, including the guard against a misplaced child.ChallengeSolve · MediumChallengeSolve · Medium
- withLogger HOCProA contrasting sharing pattern — composition through wrapping rather than through an implicit shared context.ChallengeSolve · MediumChallengeSolve · Medium
- Fundamentals · Component PatternsComponent-pattern questions that include when composition beats a large configuration-object prop.QuizTake · MediumQuizTake · Medium
Read next
Render Props vs Higher-Order Components
The two pre-hooks patterns for sharing behavior between components, their well-known pitfalls, and why a hook replaces both.
3 min
Portals and Rendering Outside the Tree
How createPortal splits DOM position from React-tree position, why modals need it, and where events actually bubble.
3 min