10LOC
Reactadvanced

Compound components with context instead of Children.map

Published April 20, 2027

import { createContext, useContext, useState, type ReactNode } from "react";

type TabsState = { activeTab: string; setActiveTab: (id: string) => void };
const TabsContext = createContext<TabsState | null>(null);

const useTabsState = () => {
  const context = useContext(TabsContext);
  if (!context) throw new Error("Tabs.Tab and Tabs.Panel must be rendered inside <Tabs>");
  return context;
};

const TabsRoot = ({ defaultTab, children }: { defaultTab: string; children: ReactNode }) => {
  const [activeTab, setActiveTab] = useState(defaultTab);
  return <TabsContext.Provider value={{ activeTab, setActiveTab }}>{children}</TabsContext.Provider>;
};

const Tab = ({ id, children }: { id: string; children: ReactNode }) => {
  const { activeTab, setActiveTab } = useTabsState();
  return (
    <button role="tab" aria-selected={activeTab === id} onClick={() => setActiveTab(id)}>
      {children}
    </button>
  );
};

const Panel = ({ id, children }: { id: string; children: ReactNode }) =>
  useTabsState().activeTab === id ? <div role="tabpanel">{children}</div> : null;

export const Tabs = Object.assign(TabsRoot, { Tab, Panel });

What

A compound component splits a UI pattern (tabs, an accordion, a select) into several components that share implicit state through context, so the caller composes them like plain markup instead of passing a children array for the parent to inspect and clone.

Why it matters

The Children.map/cloneElement approach to composability — a parent iterating its children and injecting props into each one — works, but it's brittle: it usually assumes children are a flat, direct list of a specific component type, breaks the moment someone wraps a <Tabs.Tab> in a <div> for styling, and requires cloning to inject props, which is easy to get subtly wrong with keys and refs. Context sidesteps all of it: Tabs provides the shared state once, and Tab/Panel read it themselves, wherever they end up in the tree — nested, reordered, or conditionally rendered, it doesn't matter.

How it works

  • TabsContext carries the one piece of shared state (activeTab) and the way to change it (setActiveTab); useTabsState is a thin wrapper that also throws a clear error if Tab/Panel are used outside a Tabs.
  • TabsRoot owns the actual useState and provides it; it's kept separate from the exported Tabs so the static properties can be attached afterward.
  • Tab and Panel are ordinary components that call useTabsState() independently — neither receives activeTab as a prop from a parent that inspected its children.
  • Object.assign(TabsRoot, { Tab, Panel }) attaches Tab and Panel as properties on the exported Tabs, which is what makes <Tabs.Tab>/<Tabs.Panel> valid JSX while keeping TypeScript's type of Tabs accurate (typeof TabsRoot & { Tab; Panel }).

Gotchas

  • This minimal version has no keyboard navigation or a role="tablist" wrapper — a production tab component needs both for full ARIA compliance; they're left out here (along with the Object.assign typing tax) to keep the state-sharing pattern the focus, which is why the snippet keeps lineWaiver: true.
  • Because Tab/Panel throw if rendered outside Tabs, that error only surfaces at render time, not at the type level — TypeScript won't stop you from rendering <Tabs.Tab> at the top level of a file by mistake.
  • Attaching methods via Object.assign after the fact means Tab/Panel must be defined before the Object.assign call; hoisting doesn't save you here since they're consts, not function declarations.

Related

  • Children.map/cloneElement — the imperative alternative this pattern replaces; still the right tool when you genuinely need to transform arbitrary, unknown children rather than coordinate a known, fixed set of sub-components.
  • The context selector pattern (elsewhere on this blog) — solves a different problem (avoiding re-renders on unrelated state), but composes naturally with this one if Tabs state grows beyond a single string.