Compound Components
The compound components pattern lets a parent and its children cooperate to deliver one feature while the consumer composes them freely in JSX. Instead of cramming everything behind a configuration object, you expose a small family of components—<Tabs>, <Tabs.List>, <Tabs.Tab>, <Tabs.Panel>—that share state implicitly through context. The result reads like declarative HTML, gives users control over markup and ordering, and keeps the wiring hidden inside the library. You see it everywhere: <select>/<option> in the DOM, and component kits like Radix and Reach UI.
The problem with config-driven APIs
A common first attempt is to drive a component entirely through props. It works, but it gets rigid fast.
<Tabs
tabs={[
{ label: "Profile", content: <Profile /> },
{ label: "Settings", content: <Settings /> },
]}
activeIndex={0}
/>
The moment a designer wants an icon beside one label, a badge on another, or a divider between tabs, the prop schema balloons. Every new requirement becomes a new prop. Compound components flip this around: the consumer writes the JSX, and the parent supplies the shared behavior.
Sharing implicit state with context
The key insight is that the parent owns the state (which tab is active) and exposes it to descendants through a React context. Children read that context to know how to render and to dispatch updates. Because the state travels through context rather than props, the children can sit anywhere in the tree and in any order.
import { createContext, useContext, useState, useId } from "react";
const TabsContext = createContext(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error("Tabs.* must be rendered inside <Tabs>");
}
return ctx;
}
export function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue);
const baseId = useId();
return (
<TabsContext.Provider value={{ active, setActive, baseId }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
Tip: Throw a clear error from your
useTabshook when the context isnull. It turns “Cannot read property of null” into “Tabs.Tab must be rendered inside<Tabs>”—a message your users can act on.
Attaching the children as static properties
Exposing children as properties of the parent (Tabs.Tab) is a convention, not a requirement, but it signals the relationship and keeps imports tidy. Each child reads context to decide its own appearance and to update shared state.
function TabList({ children }) {
return (
<div className="tabs-list" role="tablist">
{children}
</div>
);
}
function Tab({ value, children }) {
const { active, setActive, baseId } = useTabs();
const selected = active === value;
return (
<button
role="tab"
id={`${baseId}-tab-${value}`}
aria-selected={selected}
aria-controls={`${baseId}-panel-${value}`}
className={selected ? "tab tab-active" : "tab"}
onClick={() => setActive(value)}
>
{children}
</button>
);
}
function Panel({ value, children }) {
const { active, baseId } = useTabs();
if (active !== value) return null;
return (
<div
role="tabpanel"
id={`${baseId}-panel-${value}`}
aria-labelledby={`${baseId}-tab-${value}`}
className="tab-panel"
>
{children}
</div>
);
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = Panel;
Using the finished API
Notice how expressive the call site becomes. The consumer arranges the markup, adds whatever extra elements they like, and never touches the active-tab bookkeeping.
function Account() {
return (
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Tab value="profile">👤 Profile</Tabs.Tab>
<Tabs.Tab value="settings">⚙️ Settings</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile">
<h2>Your profile</h2>
</Tabs.Panel>
<Tabs.Panel value="settings">
<h2>Your settings</h2>
</Tabs.Panel>
</Tabs>
);
}
Output:
[ 👤 Profile ] [ ⚙️ Settings ]
-----------------------------------
Your profile
Clicking Settings flips active in the parent, every descendant re-reads context, and the matching panel renders—no props threaded by hand.
Controlled and uncontrolled modes
A polished compound component supports both an internal defaultValue and an external value/onChange pair, mirroring how native form inputs work. This lets users opt into full control when they need it.
export function Tabs({ value, defaultValue, onChange, children }) {
const [internal, setInternal] = useState(defaultValue);
const active = value ?? internal; // controlled if `value` is set
const setActive = (next) => {
if (value === undefined) setInternal(next);
onChange?.(next);
};
const baseId = useId();
return (
<TabsContext.Provider value={{ active, setActive, baseId }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
When to reach for it
| Use compound components when… | Prefer a simpler API when… |
|---|---|
| Children have a parent-child relationship (tabs, accordion, menu, select) | The component takes a flat list of data and renders it |
| Consumers need to control markup, ordering, or styling | The markup is fixed and never customized |
| Multiple pieces must share one source of truth | There is no shared state between parts |
| You want a declarative, HTML-like API | A single prop already expresses everything |
Best Practices
- Keep all shared state in the parent and expose it through one context; children should be thin readers of that context.
- Guard the context with a custom hook that throws a descriptive error when used outside the provider.
- Attach children as static properties (
Tabs.Tab) to communicate the relationship and simplify imports. - Derive
ids withuseIdand wire uprole,aria-selected, andaria-controlsso the pattern is accessible by default. - Support controlled and uncontrolled modes so consumers can either let the component manage state or drive it themselves.
- Avoid passing state down as props through every level—that defeats the point; let context do the work.
- Memoize the context value if the parent re-renders frequently, to limit re-renders in deep trees.