codejump

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

Waiting at the end of this page.

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.

JSX
<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:

JSX
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:

JSX
<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 quiz

Reading this page is the cheap half. These are the exercises that make you use it.