Compound components: parts that share a Root's state
The Root owns the state and the parts read it from context; cloneElement reaches only what the Root was handed.
Predict first
Before you read: predict
ExamHall's question bar. Development build, no StrictMode.
A candidate clicks q2, then q3.
What does the bar show after the second click, and what does the console show?
function QuestionNav({ children }) { const [current, setCurrent] = useState('q1'); return ( <nav> {Children.map(children, (child) => cloneElement(child, { isCurrent: child.props.value === current, onPick: () => setCurrent(child.props.value), }))} <p>Showing {current}</p> </nav> );}function Item({ value, isCurrent, onPick }) { return <button aria-current={isCurrent ? 'step' : undefined} onClick={onPick}>{value}</button>;}function Tooltip({ text, children }) { return <span title={text}>{children}</span>;} <QuestionNav> <Item value="q1" /> <Item value="q2" /> <Tooltip text="Flagged for review"><Item value="q3" /></Tooltip></QuestionNav>The 30-second version
A compound component is a Root plus parts that callers arrange in JSX. The Root owns the state; the parts read it. Cloned props reach only the elements the Root was handed: a part inside a wrapper gets nothing.
The Root owns the state and the parts read it from context; cloneElement reaches only what the Root was handed.
How it works
Step 1 of 10
Root, Trigger, Portal, Content and Item. Callers place the parts; the Root holds their shared state.In the kitchen
- The caller runs the dance; each set chooses where on the floor it stands → The Root keeps the state; callers arrange the parts (steps 1, 10)
Analogy: A ceilidh caller on the hall's speakers
| In the hall | In React |
|---|---|
| The caller calls each figure through the speakers; every dancer hears it, wherever they stand | Parts read the Root's context wherever they render (step 9) |
| Before speakers, the caller walked the front row telling each person there their part | cloneElement on the direct children (steps 2–3) |
| A dancer standing behind a pillar hears nothing | A wrapped part gets nothing (step 5) |
| The caller runs the dance; each set chooses where on the floor it stands | The Root keeps the state; callers arrange the parts (steps 1, 10) |
In real terms: a part works wherever the caller puts it only if it reads the Root's state itself, from context.
Breaks down when: dancers can watch each other and copy a figure; a part sees only what the Root provides.
Contrasting case: a hall with one dancer who takes every instruction directly. That is a single component with props: nothing to arrange, and nothing lost on the way. It is the better choice while a component has one layout and one caller: each part is another name to learn and another place an arrangement can go wrong. Split into parts when two callers need different markup around the same state, and hang each part on the Root (QuestionNav.Item) or export it beside it (Select.Item), so a caller imports one name per part.
Examples, run for real
4.1 ExamHall EXH-4100 and MR !4400: the Arabic layout's cells
ExamHall's QuestionNav cloned isCurrent and onPick onto its children. The Arabic layout team wrapped each QuestionNav.Item in its own Cell component, which places it in the right-to-left grid. MR !4400 moves the state into a context; the markup does not change.
4.1 EXH-4100 clone Root, every Item inside <Cell>: clicks that move the current question 0 of 4 · screen "Showing q1"
4.1 MR !4400 context Root, same markup: clicks that move the current question 4 of 4 · screen "Showing q5"
The clones went to the four Cells (steps 3, 5), so no click changed anything, and no warning said why. Through context, each Item reads the Root's state inside its cell (step 9), and QuestionNav gained no prop (step 10).
4.2 Payments ledger: one Root, two layouts
A statement's Root provides the active filter; Filter chips and a Title read it. The web statement puts the title first and the chips in a row. The mobile one puts the chips inside its own Sheet component, under the list.
4.2 web: title first, chips in a row click Refunds -> "Statement: refunds" · chips pressed 1
4.2 mobile: chips inside <Sheet>, title last click Refunds -> "Statement: refunds" · chips pressed 1
Each chip carries its own value (step 8) and reads the filter from context, so both layouts work with no new prop (steps 9–10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
An MR turns a ledger statement's two buttons into tabs. Development build, no StrictMode.
A user clicks Refunds once.
Which tab is selected, and what happened to track?
function StatementTabs({ children }) { const [current, setCurrent] = useState('payments'); return ( <div role="tablist"> {Children.map(children, (child) => cloneElement(child, { 'aria-selected': child.props.value === current, onClick: () => setCurrent(child.props.value), }))} </div> );} <StatementTabs> <button role="tab" value="payments">Payments</button> <button role="tab" value="refunds" onClick={() => track('refunds_tab')}>Refunds</button></StatementTabs>Question 2 of 7
A merchant's ledger tabs. openDisputes comes from the server. Development build, no StrictMode.
With openDisputes at 0, the merchant clicks Refunds and the heading reads "Refunds". A chargeback then opens a dispute, and openDisputes becomes 1.
What does the heading read now?
function LedgerTabs({ children }) { const tabs = Children.toArray(children); const [index, setIndex] = useState(0); return ( <> <div role="tablist"> {tabs.map((tab, i) => cloneElement(tab, { selected: i === index, onSelect: () => setIndex(i) }))} </div> <h2>{tabs[index].props.label}</h2> </> );} <LedgerTabs> <Tab label="Payments" /> {openDisputes > 0 && <Tab label="Disputes" />} <Tab label="Refunds" /> <Tab label="Payouts" /></LedgerTabs>Question 3 of 7
The ledger's method filter. The payment-methods team supplies methodTabs. Development build, no StrictMode.
A user clicks Card once.
Which tabs show as selected?
function MethodTabs({ children }) { const [current, setCurrent] = useState('all'); return ( <div role="tablist"> {Children.map(children, (child) => cloneElement(child, { selected: child.props.value === current, onSelect: () => setCurrent(child.props.value), }))} </div> );}function Tab({ label, selected, onSelect }) { return <button role="tab" aria-selected={!!selected} onClick={onSelect}>{label}</button>;}const methodTabs = ( <> <Tab value="card" label="Card" /> <Tab value="bank" label="Bank transfer" /> </>); <MethodTabs> <Tab value="all" label="All" /> {methodTabs}</MethodTabs>Question 4 of 7
A payout onboarding checklist. The compliance team extracted its two steps into a component. Development build, no StrictMode.
What does the Bank account line read?
function Steps({ children }) { const total = Children.count(children); return <ol>{Children.map(children, (child, i) => cloneElement(child, { n: i + 1, total }))}</ol>;}function Step({ n, total, title }) { return <li>Step {n} of {total}: {title}</li>;}function KycSteps() { return <><Step title="Upload ID" /><Step title="Selfie check" /></>;} <Steps> <Step title="Business details" /> <KycSteps /> <Step title="Bank account" /></Steps>Question 5 of 7
Write QuestionNav from a blank file, with two parts, QuestionNav.Item (value; a button, aria-current="step" on the current question, a click makes it current) and QuestionNav.Flag (value; a toggle button with aria-pressed, labelled Flag <value>). QuestionNav takes defaultValue. An Item must work directly inside QuestionNav, inside another component, inside a Fragment and when another component renders it, and a Flag may come before its Item.
Export QuestionNav.
Question 6 of 7
A transfer form's currency picker. Development build, no StrictMode.
In the DOM, the listbox is a child of <body>, outside the picker's <span>. The user clicks the second option once.
What do the picker and the menu show?
const CurrencyContext = createContext(null);function CurrencyPicker({ children }) { const [currency, setCurrency] = useState('EUR'); return ( <CurrencyContext value={{ currency, setCurrency }}> <span>{currency}</span> {children} </CurrencyContext> );}function Menu({ children }) { return createPortal(<div role="listbox">{children}</div>, document.body);}function Option({ code }) { const { currency, setCurrency } = useContext(CurrencyContext); return <button onClick={() => setCurrency(code)}>{code}{currency === code ? ' ✓' : ''}</button>;} <CurrencyPicker> <Menu> <Option code="EUR" /> <Option code="USD" /> </Menu></CurrencyPicker>Question 7 of 7
A currency picker. Tooltip comes from a UI library you cannot edit; PinnedOption is the treasury team's. Development build, no StrictMode.
Only EUR selects when clicked.
Which single change makes a click on each of the four options select it?
function CurrencyPicker({ children }) { const [currency, setCurrency] = useState('none'); return ( <div role="group"> {Children.map(children, (child) => cloneElement(child, { selected: child.props.code === currency, onPick: () => setCurrency(child.props.code), }))} <output>{currency}</output> </div> );}function PinnedOption({ code }) { return <><i>pinned</i><Option code={code} /></>;} <CurrencyPicker> <Option code="EUR" /> <Tooltip text="Most used"><Option code="USD" /></Tooltip> <PinnedOption code="GBP" /> <><Option code="CHF" /></></CurrencyPicker>Go deeper
Pro tips
- Throw a clear error from the parts' context hook. When a part renders outside its Root, a
nullcontext then fails with "<QuestionNav.Item>must be rendered inside<QuestionNav>" instead of a blank screen. When: every compound component you hand to other teams. Verify (RAN,tips.mjs): the first line below: the kata reference, which has no guard, fails on thenullcontext; a copy with the guard added throws the clear message. The hook guard itself ispat-react-provider-pattern's lane. - Test each part inside a wrapper component, not only as a direct child. A clone-based Root passes every direct-child test and fails the first wrapped one. When: reviewing a compound component's tests. Verify (RAN,
tips.mjs): the second line below; the kata's K3 is such a test.
tip 1 reference Item outside QuestionNav: TypeError, property 'current' of null · with the guard added: throws "<QuestionNav.Item> must be rendered inside <QuestionNav>"
tip 2 clone-variant.jsx: direct-child tests K1, K2 pass · wrapped-part test K3 fails
Source
- react.dev: Children, read 2026-10-13 from the page's source. "Using
Childrenis uncommon and can lead to fragile code." "The traversal does not go deeper than React elements: they don't get rendered, and their children aren't traversed. Fragments don't get traversed." "There is no way to get the rendered output of an inner component like<MoreRows />when manipulatingchildren." Steps 3–6, P2, P3, P4. Valid as of 2026-10-13. - react.dev: Passing Data Deeply with Context, read 2026-10-13. "You can insert as many components as you like between the component that provides context and the one that uses it. This includes both built-in components like
<div>and components you might build yourself." Step 9. Valid as of 2026-10-13. - Radix Primitives: Select, read 2026-10-13. Anatomy: "Import all parts and piece them together.", with
Select.Root,Select.Trigger,Select.Portal,Select.ContentandSelect.Item. Steps 1 and 10. Valid as of 2026-10-13.
Related lessons
- Read first: Context vs external stores
- Read first: Design systems and component APIs
- Related: Controlled and uncontrolled component APIs
- Related: UI build: accessible tabs and accordion
- Related: Focus and keyboard management
- Related: The provider pattern: context plus a guarded hook
- Related: Headless components: behaviour and ARIA without markup
Checked against the tools and docs as of 2026-10-13.