Compound Components with Context
A compound component is not a single mega-component with twenty boolean props. It is a family of components that are only meaningful together: a root that owns state, and children that participate in that state without prop-drilling through every intermediate wrapper. The pattern shows up wherever the UI is structured as slots—tabs, menus, accordions, split panes—and you want callers to arrange those slots in JSX while the library enforces invariants behind the scenes.
Context is the natural implementation mechanism. The root establishes a provider; leaf pieces read the shared value. What separates a toy demo from a maintainable API is how you type the contract, fail fast when the contract is violated, and make the family discoverable in editors and DevTools.
Why context instead of cloneElement
Historically, some libraries reached for React.Children.map and cloneElement to inject props
into children. That approach is brittle: it breaks when consumers insert fragments, conditional
children, or custom wrappers, and it fights TypeScript because the static shape of children is not a
props object you can infer cleanly.
Context inverts the problem. Children opt in by calling useContext (or a small wrapper hook). The
tree structure stays ordinary React; only the implicit wiring moves into a typed channel. The
cost is abstraction: consumers must understand that Tab cannot live outside Tabs. A dedicated
hook that throws a clear error turns that cost into an explicit guardrail.
A typed Tabs compound component
The following example shows a minimal but realistic pattern: a context value carries the active tab
id and a setter; subcomponents read it; the root owns state. Static attachment
(Tabs.List = TabList) gives you a namespace in usage—<Tabs.List> reads like a small DSL and
groups exports in documentation.
interface TabsContextValue {
activeTab: string
setActiveTab: (id: string) => void
}
const TabsContext = createContext<TabsContextValue | null>(null)
function useTabs() {
const ctx = useContext(TabsContext)
if (!ctx) throw new Error('Tab components must be used within <Tabs>')
return ctx
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: React.ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
function TabList({ children }: { children: React.ReactNode }) {
return <div role="tablist">{children}</div>
}
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab, setActiveTab } = useTabs()
return (
<button
role="tab"
aria-selected={activeTab === id}
onClick={() => setActiveTab(id)}
>
{children}
</button>
)
}
function TabPanel({ id, children }: { id: string; children: React.ReactNode }) {
const { activeTab } = useTabs()
if (activeTab !== id) return null
return <div role="tabpanel">{children}</div>
}
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
Usage stays declarative and mirrors the visual hierarchy:
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="details">Details</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview">...</Tabs.Panel>
<Tabs.Panel id="details">...</Tabs.Panel>
</Tabs>
Design principles encoded in this shape
First, single source of truth. activeTab lives in one place. Panels do not maintain parallel
state that can drift; tabs do not receive redundant isSelected props that could disagree with the
root.
Second, composition over configuration. You are not forced to pass arrays of
{ id, label, content } into a monolith. That array style is fine for simple cases, but compound
components scale better when panels contain arbitrary subtrees—forms, charts, lazy routes—without
the root needing to know.
Third, accessibility as a first-class layout concern. The example wires role="tablist",
role="tab", role="tabpanel", and aria-selected. A production component would also associate
tabs with panels via aria-controls and id, manage keyboard navigation (ArrowLeft /
ArrowRight, Home / End), and handle focus visibility. The compound structure makes those
concerns local: keyboard behavior can live in TabList or Tab without leaking props through
every parent.
TypeScript: enforcing valid usage
The useTabs hook is doing important type-level work. By returning a non-null context value or
throwing, you avoid sprinkling ctx! through child components and you document the invariant in one
place. If you need stricter APIs— for example, ensuring TabPanel ids match a known union—you can
thread generics through the context:
type TabId = "overview" | "details";
interface TabsContextValue<T extends string = string> {
activeTab: T;
setActiveTab: (id: T) => void;
}
That moves invalid ids from “silent UI bug” to “type error at the call site,” which is exactly where experienced TypeScript developers want failures to land.
displayName and DevTools ergonomics
When you attach subcomponents as properties (Tabs.List = TabList), minifiers can mangle function
names in production bundles. Set displayName on each piece so React DevTools shows Tabs.List
rather than Anonymous. This is not vanity; it is operational clarity when you are debugging a
design system in a minified staging build.
Conventionally, mirror the public API in the name: TabList.displayName = 'Tabs.List'. The same
applies if you export a hook: useTabs.displayName is not used by DevTools, but naming the hook
distinctly (useTabsContext) avoids collisions with domain language.
Controlled mode and lifting state without losing the API
The sample Tabs uses defaultTab and internal useState, which is uncontrolled from the
parent’s perspective. Design systems almost always add a controlled variant: activeTab and
onActiveTabChange props on the root that, when provided, replace internal state. TypeScript can
express the mutual exclusion—either you pass defaultTab or you pass the controlled pair—using a
discriminated union on props so callers cannot accidentally pass both and get undefined behavior.
Controlled mode matters when tabs must sync with the URL, with persisted UI preferences, or with
wizard steps validated on the server. The compound surface stays the same; only the root’s state
source changes. That is a strong reason to keep subcomponents dumb: they continue to call
setActiveTab from context while the root decides whether that setter updates local state or
forwards to the parent.
Splitting context to shrink rerender blast radius
When profiling shows tab switches re-rendering heavy panel subtrees unnecessarily, consider two
contexts—one for rarely changing identifiers and one for the active selection—or a pattern similar
to React Redux’s split between state and dispatch. Consumers that only need stable callbacks
subscribe to a dispatch context memoized with useCallback; consumers that must repaint on every
change subscribe to the state slice. Document which subcomponents need which hook so future
contributors do not accidentally widen subscriptions.
When compound components hurt
The pattern shines for coherent widgets with shared state. It is weaker when children are
arbitrary and should not know about each other, or when you need to virtualize a list of tab
triggers—sometimes a controlled, data-driven API is simpler. Also watch re-render scope: a
context value that changes frequently can invalidate a large subtree. Mitigations include splitting
context (state vs dispatch), memoizing callbacks with useCallback, and storing volatile values in
refs when subscribers do not need to re-render on every tick.
In design systems
Internal libraries benefit from compound components because they communicate structure. New
contributors see <Card.Header> and infer boundaries without reading prose docs. Pair the pattern
with Storybook stories that show forbidden compositions (what error do you get if Tab is
orphaned?) and with tests that render the full compound tree under different states.
Used well, compound components with context are how you build React APIs that feel designed rather than accumulated: a small state machine at the root, typed hooks at the leaves, and JSX that reads like the product you are building.