Skip to content

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?

Code for this question
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>
Pick one

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

A Root and its parts. Radix's Select is 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?

Code for this question
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>
Pick one

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?

Code for this question
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>
Pick one

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?

Code for this question
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>
Pick one

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?

Code for this question
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>
Pick one

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?

Code for this question
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>
Pick one

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?

Code for this question
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>
Pick one

Go deeper

Pro tips
  1. Throw a clear error from the parts' context hook. When a part renders outside its Root, a null context 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 the null context; a copy with the guard added throws the clear message. The hook guard itself is pat-react-provider-pattern's lane.
  2. 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 Children is 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 manipulating children." 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.Content and Select.Item. Steps 1 and 10. Valid as of 2026-10-13.

Checked against the tools and docs as of 2026-10-13.