The Problem with Monolithic Component APIs
Imagine building a <Tabs> component. The first instinct is to accept everything as props: tabs, activeTab, onTabChange, renderTab, renderPanel, tabClassName, panelClassName… The list grows with every new consumer. Each new layout requirement — putting a button next to the tab bar, nesting a badge inside a label — means adding yet another prop or escape hatch. The component becomes a configuration object masquerading as a UI element.
The compound components pattern solves this by splitting the monolithic component into a family of cooperating sub-components. The parent owns shared state; the sub-components consume it implicitly. The consumer controls layout by composing sub-components in JSX, just like standard HTML elements:
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview">Overview <Badge count={3} /></Tabs.Tab>
<Tabs.Tab value="settings">Settings</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview"><OverviewContent /></Tabs.Panel>
<Tabs.Panel value="settings"><SettingsContent /></Tabs.Panel>
</Tabs>
The consumer inserted a <Badge> inside a tab label without any prop. The tab bar and panels can be separated by arbitrary elements. This is the key benefit: layout flexibility without API sprawl.
Sharing State with Context
The mechanism that makes this work is a private Context. The parent component creates it, provides the shared state, and keeps the Context object unexported — it is an internal implementation detail. Sub-components reach into the Context to read what they need.
const TabsContext = createContext(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('<Tabs.Tab> must be rendered inside <Tabs>');
return ctx;
}
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
}
Tabs.Tab = function TabsTab({ value, children }) {
const { active, setActive } = useTabs();
return (
<button
role="tab"
aria-selected={active === value}
onClick={() => setActive(value)}
>
{children}
</button>
);
};
Wrapping the Context in a custom hook with a guard (useTabs) is a best practice. When a <Tabs.Tab> is rendered outside a <Tabs>, it throws a clear error message instead of silently returning undefined and causing a cryptic crash downstream.
The Dot-Notation API
Attaching sub-components as static properties on the parent (Tabs.Tab, Tabs.Panel) groups the whole family under one import and signals their relationship visually in JSX. This is the dominant style in design system libraries like Radix UI, Headless UI, and Mantine.
Tabs.List = TabsList;
Tabs.Tab = TabsTab;
Tabs.Panel = TabsPanel;
export default Tabs; // one import, four components
An alternative is named exports (export { Tabs, TabsList, TabsTab }), which works well with tree-shaking but loses the visual grouping at the import site. For shared design system components, dot-notation wins; for internal app components, named exports are fine.
Controlled vs Uncontrolled Modes
Good compound components support both usage modes, mirroring native HTML inputs. In uncontrolled mode the caller passes defaultValue and the component manages state internally. In controlled mode the caller passes value and onChange and owns the state.
function Tabs({ value, defaultValue, onChange, children }) {
const [internal, setInternal] = useState(defaultValue ?? null);
const isControlled = value !== undefined;
const active = isControlled ? value : internal;
function handleChange(next) {
if (!isControlled) setInternal(next);
onChange?.(next);
}
return (
<TabsContext.Provider value={{ active, handleChange }}>
{children}
</TabsContext.Provider>
);
}
Never switch between modes at runtime — if value goes from defined to undefined, log a warning (React's controlled input warning does exactly this).
The React.Children Approach (Legacy)
Before Context became ergonomic, a common technique was React.Children.map + React.cloneElement to inject state as props into direct children. Interviewers ask about this to test historical knowledge. Know its fatal flaw: it only reaches direct children. Wrapping a <Tabs.Tab> in a <div> for layout breaks the injection chain entirely. Prefer Context for all new compound components.
TypeScript Typing
Three things need types: the Context shape, each sub-component's props, and the parent augmented with its sub-component statics.
interface TabsContextValue {
active: string;
handleChange: (v: string) => void;
}
interface TabsComponent extends React.FC<TabsProps> {
List: React.FC<{ children: React.ReactNode }>;
Tab: React.FC<{ value: string; children: React.ReactNode }>;
Panel: React.FC<{ value: string; children: React.ReactNode }>;
}
const Tabs = function Tabs(props: TabsProps) { /* ... */ } as TabsComponent;
Tabs.List = TabsList;
Tabs.Tab = TabsTab;
Tabs.Panel = TabsPanel;
The as TabsComponent cast is necessary because TypeScript can't infer static properties from assignment.
Performance: Context Re-render Trap
Every consumer of a Context re-renders when the context value reference changes. If you create a new object literal on every render, all sub-components re-render even when the active tab didn't change.
// Problem: new object reference each render
<TabsCtx.Provider value={{ active, setActive }}>
// Fix 1: stabilise with useMemo
const ctx = useMemo(() => ({ active, setActive }), [active]);
<TabsCtx.Provider value={ctx}>
// Fix 2: split into state + dispatch contexts
const TabsStateCtx = createContext(null); // rerenders on active change
const TabsDispatchCtx = createContext(null); // stable — setActive never changes
Splitting into state and dispatch contexts is the most effective solution: components that only call setActive subscribe to the dispatch context and never re-render when the active value changes. Start with a single context; split only when profiling confirms unnecessary re-renders.
React Server Components Caveat
Context is a client-only API. Compound components that use it must be marked 'use client', making the compound root a client boundary. You can still pass server-rendered children into a client compound component — RSC allows server components to appear as children of client components:
'use client'; // compound component must be a client component
// tabs.tsx — full compound implementation
// page.tsx (server component)
import Tabs from './tabs';
export default function Page() {
return (
<Tabs defaultValue="overview">
<Tabs.Panel value="overview">
<ServerDataTable /> {/* server component passed as children */}
</Tabs.Panel>
</Tabs>
);
}
Testing Strategy
Test compound components through their composed API — render the parent with sub-components just as a real consumer would. Never import and render sub-components in isolation; they depend on the Context being present.
test('switches panels on tab click', async () => {
render(
<Tabs defaultValue="a">
<Tabs.List>
<Tabs.Tab value="a">Tab A</Tabs.Tab>
<Tabs.Tab value="b">Tab B</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="a">Content A</Tabs.Panel>
<Tabs.Panel value="b">Content B</Tabs.Panel>
</Tabs>
);
await userEvent.click(screen.getByRole('button', { name: 'Tab B' }));
expect(screen.getByText('Content B')).toBeVisible();
});
Test behavior — what users see and can do — not implementation details like which Context value changed.
Key Takeaways
- Compound components solve prop explosion by splitting a monolithic component into cooperating sub-components that share state via a private Context.
- The parent provides state through Context; sub-components consume it without the caller wiring anything up.
- A custom hook with a guard (
if (!ctx) throw) is mandatory — it turns silent misuse into a clear error. - The dot-notation API (
Tabs.Tab) groups related components under one import and signals their relationship. - Support both controlled (
value/onChange) and uncontrolled (defaultValue) modes. - Stabilise the Context value with
useMemoor split into state + dispatch contexts to avoid unnecessary re-renders. - In RSC, the compound root must be
'use client'; server-component children can still be passed in. - Always test through the composed API; sub-components tested in isolation will fail because there is no Context provider.