Must knowAdvanced React Patterns · 03

Compound Components

Toggle, ToggleButton, ToggleOn and ToggleOff sharing state through context, like select and option.

On this page · 7 sections
  1. In one minute
  2. The example: “Gift wrap”
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

Context
GiftWrap.tsx
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>
What you see
Gift wrap: off
No gift wrap
  1. render phasereturn <ToggleContext value={{ on, toggle: () => setOn(!on) }}>Toggle owns the state and puts it in context around whatever children it was given.
  2. render phaseconst { on } = useToggleContext()Each piece reads the context, however deep it is. The consumer’s div in between doesn’t matter.
  3. event<ToggleButton />Click the switch: on becomes true, the context value changes, and all the pieces re-render.
  4. commit phaseRecorded: the text changed with the switch.

0 / 0
A gift-wrap toggle built from compound components. The consumer wrapped the two texts in a 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

Common mistakes

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.

Sources

Primary source: kentcdodds.com/blog/compound-components-with-react-hooks