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
TabsContextcarries the one piece of shared state (activeTab) and the way to change it (setActiveTab);useTabsStateis a thin wrapper that also throws a clear error ifTab/Panelare used outside aTabs.TabsRootowns the actualuseStateand provides it; it's kept separate from the exportedTabsso the static properties can be attached afterward.TabandPanelare ordinary components that calluseTabsState()independently — neither receivesactiveTabas a prop from a parent that inspected its children.Object.assign(TabsRoot, { Tab, Panel })attachesTabandPanelas properties on the exportedTabs, which is what makes<Tabs.Tab>/<Tabs.Panel>valid JSX while keeping TypeScript's type ofTabsaccurate (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 theObject.assigntyping tax) to keep the state-sharing pattern the focus, which is why the snippet keepslineWaiver: true. - Because
Tab/Panelthrow if rendered outsideTabs, 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.assignafter the fact meansTab/Panelmust be defined before theObject.assigncall; hoisting doesn't save you here since they'reconsts, 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
Tabsstate grows beyond a single string.