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.