In one minute
Compound components are a set of components that only make sense together and share state implicitly, like HTML’s <select> and <option>: you don’t wire each <option> to the select, it just works. In React, the parent (Toggle) owns the state and puts it in context; the pieces (ToggleButton, ToggleOn, ToggleOff) read it. The consumer decides which pieces to render, in what order, wrapped in whatever markup they like. A small hook that throws a clear error when a piece is used outside its parent makes the API safe.
You’ll be able to: build a compound component with context, explain why the older cloneElement version breaks, and give a missing parent a helpful error.
1function Toggle({ children }) {2 const [on, setOn] = useState(false)3 return <ToggleContext value={{ on, toggle: () => setOn(!on) }}>4 {children}5 </ToggleContext>6}7function ToggleOn({ children }) {8 const { on } = useToggleContext()9 return on ? children : null10}1112<Toggle>13 <ToggleButton />14 <div className="note">15 <ToggleOn>We'll wrap it in recycled paper 🎁</ToggleOn>16 <ToggleOff>No gift wrap</ToggleOff>17 </div>18</Toggle>- render phase
return <ToggleContext value={{ on, toggle: () => setOn(!on) }}>Toggleowns the state and puts it in context around whatever children it was given. - render phase
const { on } = useToggleContext()Each piece reads the context, however deep it is. The consumer’sdivin between doesn’t matter. - event
<ToggleButton />Click the switch:onbecomes true, the context value changes, and all the pieces re-render. - commit phaseRecorded: the text changed with the switch.
1function ToggleClone({ children }) {2 const [on, setOn] = useState(false)3 return Children.map(children, child =>4 typeof child.type !== 'string' // skip <div>, <p>…5 ? cloneElement(child, { on, toggle: () => setOn(!on) })6 : child,7 )8}910<ToggleClone>11 <CloneButton />12 <div className="note">13 <CloneOn>We'll wrap it in recycled paper 🎁</CloneOn>14 <CloneOff>No gift wrap</CloneOff>15 </div>16</ToggleClone>- render phase
return Children.map(children, child =>ToggleClonewalks its direct children and copiesonandtoggleonto each component element. - render phase
typeof child.type !== 'string' // skip <div>, <p>…Thedivis a DOM element, so it’s passed through untouched, andChildren.mapnever looks inside it.CloneOnandCloneOffget noonat all. - event
<CloneButton />Click the switch. It flips:CloneButtonwas a direct child. - commit phaseBut the text doesn’t change. Recorded: still “No gift wrap”.
div (both versions recorded).The example: “Gift wrap”
At checkout there’s a gift-wrap switch, with a line of text that changes with it.
1. One owner, shared through context
type ToggleValue = { on: boolean; toggle: () => void }
const ToggleContext = createContext<ToggleValue | null>(null)
function Toggle({ children }: { children: ReactNode }) {
const [on, setOn] = useState(false)
const toggle = () => setOn(!on)
return <ToggleContext value={{ on, toggle }}>{children}</ToggleContext>
}
function useToggleContext() {
const context = use(ToggleContext)
if (!context) throw new Error('Toggle components must be rendered inside <Toggle>')
return context
}
function ToggleOn({ children }: { children: ReactNode }) {
const { on } = useToggleContext()
return on ? children : null
}
function ToggleOff({ children }: { children: ReactNode }) {
const { on } = useToggleContext()
return on ? null : children
}
function ToggleButton(props: { 'aria-label': string }) {
const { on, toggle } = useToggleContext()
return <Switch on={on} onClick={toggle} {...props} />
}
2. What the consumer writes
function GiftWrap() {
return (
<Toggle>
<ToggleButton aria-label="Gift wrap" />
<div className="note">
<ToggleOn>We'll wrap it in recycled paper 🎁</ToggleOn>
<ToggleOff>No gift wrap</ToggleOff>
</div>
</Toggle>
)
}
No state and no props to wire. The texts sit inside the consumer’s own div. Recorded, before and after clicking the switch:
{
"logs": [
"scenario: context"
],
"warnings": [],
"before": "No gift wrap",
"after": "We'll wrap it in recycled paper 🎁",
"switch": "true"
}
3. The older way: cloneElement
Before hooks, compound components were often built by copying props onto children:
function ToggleClone({ children }: { children: ReactNode }) {
const [on, setOn] = useState(false)
const toggle = () => setOn(!on)
return Children.map(children, (child) =>
isValidElement(child) && typeof child.type !== 'string' // skip <div>, <p>…
? cloneElement(child as ReactElement<Injected>, { on, toggle })
: child,
)
}
With the same layout (texts inside a div):
{
"logs": [
"scenario: clone"
],
"warnings": [],
"before": "No gift wrap",
"after": "No gift wrap",
"switch": "true"
}
The switch turned on, but the text didn’t change. Children.map only sees direct children. The div is a direct child; the text components inside it never received on.
4. Used outside <Toggle>
Two buttons with no Toggle above them, one reading the context with !, one with useToggleContext:
function ToggleButtonUnchecked(props: { 'aria-label': string }) {
const { on, toggle } = use(ToggleContext)! // "!" tells TypeScript it's never null
return <Switch on={on} onClick={toggle} {...props} />
}
What each error boundary showed:
Cannot destructure property 'on' of 'use(...)' as it is null.
Toggle components must be rendered inside <Toggle>
The ! only silenced TypeScript; at runtime the code crashed with an error that doesn’t mention Toggle. The checking hook names the fix, and its throw lets TypeScript narrow the type to non-null on its own.
How it works
- Context carries the state through any depth. Readers find the nearest provider above them (Context with use), so wrappers, fragments and conditional rendering don’t break the connection.
- The parent owns state, the consumer owns structure. That split is the whole point: compared with one component that takes a big config prop (
options={[…]}), every new layout need is just JSX, not a new prop. - The context value is a new object on every render of
Toggle, so every piece re-renders whenToggledoes. That’s fine for three pieces; for many, memoize the value. - Libraries use this everywhere: Radix (
Tabs,Accordion), Reach UI, Headless UI.
Common mistakes
use(Context)!at every call site. Centralize the read in one hook that throws a clear error.- Using
cloneElementfor new code. It breaks as soon as a child is wrapped (recorded). - Exporting pieces that can’t work alone without saying so in the error message.
Interview Q&A
What are compound components?
Components designed to be used together that share state implicitly, the way <select> and <option> do. The parent owns the state; the children read it without the consumer passing props between them.
How do you build them today?
The parent puts its state ({ on, toggle }) in a context provider around children; each piece reads the context through a custom hook. The consumer composes the pieces freely.
Why not Children.map + cloneElement?
It only reaches direct children. Recorded: with the text components wrapped in a div, the switch turned on but the text stayed “No gift wrap”. Context works at any depth.
What happens if a piece is rendered without its parent?
With use(ToggleContext)!, the context is null and the code crashes with “Cannot destructure property ‘on’ of ‘use(…)’ as it is null.” A checking hook throws “Toggle components must be rendered inside <Toggle>” instead, which also narrows the TypeScript type.
What's the advantage over a single component with a config prop?
The consumer controls structure and markup with plain JSX, so the author doesn’t have to add a prop for every layout variation.
Related
- Composition and Layout Components: pass elements, not data.
- The Slots Pattern: share props by name instead of separate components per role.
- Context with use: how context reaches readers.
Sources
- Kent C. Dodds: React Hooks: Compound Components
- react.dev:
cloneElement(and its alternatives),Children - Radix UI: Tabs, a production compound component
- Topic order inspired by Kent C. Dodds’ EpicReact Advanced React Patterns workshop; the example app and code here are this handbook’s own.
Primary source: kentcdodds.com/blog/compound-components-with-react-hooks