Compound Component Pattern in React: Context + Sub-Components
Build flexible, expressive React APIs using the compound component pattern — Context-driven sub-components that give consumers control without prop drilling hell.
What the Compound Component Pattern Actually Is
The compound component pattern is a way of structuring React components so that a parent and a group of children share state implicitly — no prop drilling, no callback pyramids. Think of how HTML's <select> and <option> work together: neither element does much alone, but combined they form a complete, coherent control. You're aiming for that same ergonomic feel in your own component APIs.
In practice, the pattern uses React Context to ferry shared state from a root component down to its named sub-components. The consumer gets to compose the UI in whatever order they want. You don't expose a monster 15-prop API; you expose a handful of focused pieces that slot together. That distinction matters enormously when you're building a design system consumed by dozens of engineers.
Headless UI (v2 in 2023) popularised this pattern for production-grade libraries. Radix UI runs almost entirely on it. Reach UI did too. If you've used any of those, you've already benefitted from compound components without necessarily knowing the mechanics behind them.
The core pieces are three things: a Context object, a root component that owns state and provides that context, and a set of sub-components that read from that context. That's it. The magic is in how cleanly the consumer-facing API reads when you're done.
Building One From Scratch: A Tabs Component
Tabs are the canonical example — short enough to fit in a blog post, complex enough to actually show you something useful. Here's the full implementation with TypeScript and React 18's Context API.
// tabs.tsx
import {
createContext,
useContext,
useState,
ReactNode,
HTMLAttributes,
} from 'react';
// --- Context ---
interface TabsContextValue {
active: string;
setActive: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs sub-components must be used inside <Tabs>');
return ctx;
}
// --- Root ---
interface TabsProps {
defaultTab: string;
children: ReactNode;
}
export function Tabs({ defaultTab, children }: TabsProps) {
const [active, setActive] = useState(defaultTab);
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className="tabs-root">{children}</div>
</TabsContext.Provider>
);
}
// --- Tab List ---
export function TabList({ children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div role="tablist" {...props}>
{children}
</div>
);
}
// --- Tab Trigger ---
interface TabTriggerProps {
id: string;
children: ReactNode;
}
export function TabTrigger({ id, children }: TabTriggerProps) {
const { active, setActive } = useTabsContext();
const isActive = active === id;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActive(id)}
className={isActive ? 'tab-active' : 'tab'}
>
{children}
</button>
);
}
// --- Tab Panel ---
interface TabPanelProps {
id: string;
children: ReactNode;
}
export function TabPanel({ id, children }: TabPanelProps) {
const { active } = useTabsContext();
if (active !== id) return null;
return (
<div role="tabpanel" aria-labelledby={id}>
{children}
</div>
);
}And here's what the consumer-side usage looks like. This is the part that matters — the API your teammates actually touch day to day.
import { Tabs, TabList, TabTrigger, TabPanel } from './tabs';
export function SettingsPage() {
return (
<Tabs defaultTab="profile">
<TabList>
<TabTrigger id="profile">Profile</TabTrigger>
<TabTrigger id="security">Security</TabTrigger>
<TabTrigger id="billing">Billing</TabTrigger>
</TabList>
<TabPanel id="profile"><ProfileForm /></TabPanel>
<TabPanel id="security"><SecuritySettings /></TabPanel>
<TabPanel id="billing"><BillingPage /></TabPanel>
</Tabs>
);
}Honestly, that consumer API is beautiful. No activeTab prop threading down three levels. No onTabChange handler plumbing through intermediary components. You read it top to bottom and it just *makes sense*. The useTabsContext guard in the hook is worth calling out — it gives you an explicit error message at dev-time if someone tries to use TabTrigger outside a Tabs wrapper, which saves real debugging time.
Attaching Sub-Components as Static Properties
One common variation is attaching sub-components directly to the root component as static properties instead of exporting them separately. You get a single import and a namespaced API — <Tabs.List> instead of <TabList>. Lots of design systems go this route because it groups the API surface visually in autocomplete.
// Attach sub-components as statics
Tabs.List = TabList;
Tabs.Trigger = TabTrigger;
Tabs.Panel = TabPanel;
// consumer
import { Tabs } from './tabs';
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Trigger id="profile">Profile</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel id="profile"><ProfileForm /></Tabs.Panel>
</Tabs>Worth noting: TypeScript needs a bit of help here. You can't just slap properties onto a function declaration without telling the type system about them. The cleanest fix is to cast your component as typeof Tabs & { List: typeof TabList; Trigger: typeof TabTrigger; Panel: typeof TabPanel } or define the static assignments before export and let TS infer from there.
In practice, both patterns (separate named exports vs. static properties) work fine. Pick the one that fits your team's import conventions. If you're building something like the design system at Empire UI scale — hundreds of components — the namespaced approach helps discoverability a lot.
Controlled vs Uncontrolled Modes
The tabs example above is uncontrolled — state lives inside the root component. But most real design systems need both modes. A user might need to drive the active tab from a URL param, or sync it with some external state manager. You need to support a controlled API too.
interface TabsProps {
defaultTab?: string; // uncontrolled
activeTab?: string; // controlled
onTabChange?: (id: string) => void; // controlled
children: ReactNode;
}
export function Tabs({ defaultTab, activeTab, onTabChange, children }: TabsProps) {
const isControlled = activeTab !== undefined;
const [internalActive, setInternalActive] = useState(defaultTab ?? '');
const active = isControlled ? activeTab : internalActive;
const setActive = (id: string) => {
if (!isControlled) setInternalActive(id);
onTabChange?.(id);
};
return (
<TabsContext.Provider value={{ active, setActive }}>
<div>{children}</div>
</TabsContext.Provider>
);
}This mirrors exactly how native HTML inputs behave — value + onChange for controlled, defaultValue for uncontrolled. React's own docs call this the "controlled component" model and it's been the recommended pattern since React 16. Following that same convention keeps your component API predictable to anyone who's written React for more than six months.
Quick aside: don't mix controlled and uncontrolled in the same render cycle. If someone passes both activeTab and defaultTab, log a warning in development. The React team does this in their built-in inputs and it saves a lot of mysterious bugs down the line.
One more thing — if you're building for a component library that consumers install as a package (not copy-paste), a controlled API is essentially required. Otherwise library consumers can't integrate your tabs with their own state management, routing, or persistence layers.
Context Performance: When to Worry, When Not To
Here's a concern that comes up every time someone teaches this pattern: "won't all sub-components re-render whenever any state in the context changes?" Yes. And for most tabs, accordions, or dialogs, that's completely fine — you're rendering at most a handful of elements. Stop worrying about it.
When it *does* matter is when you have a large list of compound items — say 200 table rows each reading from a shared context — and a frequently changing value like a hover index or a selected set lives in that same context. Every update re-renders all 200 rows. That's where you split contexts.
// Split into stable and volatile contexts
const TabsStableContext = createContext<{ setActive: (id: string) => void } | null>(null);
const TabsActiveContext = createContext<string>('');
// Root provides both
export function Tabs({ children, defaultTab }: TabsProps) {
const [active, setActive] = useState(defaultTab);
return (
<TabsStableContext.Provider value={{ setActive }}>
<TabsActiveContext.Provider value={active}>
{children}
</TabsActiveContext.Provider>
</TabsStableContext.Provider>
);
}
// Trigger reads from both (needs to know if it's active and how to change state)
// Panel reads only from TabsActiveContextBy splitting, TabPanel only re-renders when active changes. TabTrigger re-renders when both change, which is fine since you want triggers to reflect the new active state visually. Look, this is micro-optimization territory for 99% of your components — but it's the right mental model to have when you hit the 1% case.
If you want to go further, useMemo on the context value object prevents unnecessary re-renders caused by referential inequality across parent re-renders. Wrap { active, setActive } in useMemo(() => ({ active, setActive }), [active]) on the provider and you're covered.
Compound Components in a Real Design System
The pattern really shines at design-system scale. When you look at how Empire UI structures its interactive components — accordions, dropdowns, dialogs, tabs, select inputs — the compound component pattern shows up everywhere because it solves the core tension in design-system component design: you want opinionated defaults but you also want consumers to rearrange, replace, or extend pieces without forking the whole component.
Consider an accordion. A naive implementation accepts an array of { title, content } objects and renders them. Fine for simple cases. But what if a consumer needs a custom render for the trigger — maybe with a badge, or an icon, or a tooltip? With the naive approach, you add yet another prop: renderTrigger. Then another: renderContent. Then triggerClassName, contentClassName... you end up with a prop sprawl that's impossible to document and painful to maintain.
With compound components, you just let consumers write <Accordion.Trigger> directly with whatever JSX they need inside it. The parent doesn't care. Context wires up the open/close state transparently. That's the whole game.
Worth noting: when you build glassmorphism components or style-heavy UI like the ones in Empire UI's library, the compound pattern is especially valuable because each sub-component can apply its own style layer without fighting a monolithic parent that tries to style everything at once. Accordion.Panel can have a backdrop-blur-md glass surface while Accordion.Trigger gets a solid border — no prop contortions needed.
Testing Compound Components
Testing compound components is straightforward if you follow one rule: test the composed API, not the internals. Don't test TabsContext directly. Don't test TabTrigger in isolation without a Tabs wrapper. Write tests that look exactly like consumer code, because that's what matters.
// tabs.test.tsx — React Testing Library
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Tabs, TabList, TabTrigger, TabPanel } from './tabs';
function TestTabs() {
return (
<Tabs defaultTab="a">
<TabList>
<TabTrigger id="a">Tab A</TabTrigger>
<TabTrigger id="b">Tab B</TabTrigger>
</TabList>
<TabPanel id="a">Content A</TabPanel>
<TabPanel id="b">Content B</TabPanel>
</Tabs>
);
}
test('shows default tab content', () => {
render(<TestTabs />);
expect(screen.getByText('Content A')).toBeInTheDocument();
expect(screen.queryByText('Content B')).not.toBeInTheDocument();
});
test('switches tab on click', async () => {
render(<TestTabs />);
await userEvent.click(screen.getByRole('tab', { name: 'Tab B' }));
expect(screen.getByText('Content B')).toBeInTheDocument();
expect(screen.queryByText('Content A')).not.toBeInTheDocument();
});
test('throws if TabTrigger used outside Tabs', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<TabTrigger id="x">X</TabTrigger>)).toThrow();
consoleSpy.mockRestore();
});That last test is the one people skip and then regret. The guard in useTabsContext is only useful if you actually verify it throws. Three tests, covers the complete public contract. Add a controlled-mode test if you implement that, and you're done.
In practice, I'd also add an accessibility test using @testing-library/jest-axe — pass the rendered compound component through axe() and assert no violations. ARIA roles like tablist, tab, and tabpanel have very specific relationship requirements that are easy to get subtly wrong, and axe catches those where unit tests miss them.
FAQ
Render props pass a function as a child or prop so the parent can call it with internal state — it's flexible but gets verbose fast. Compound components use Context to share state implicitly across multiple named sub-components, giving you a cleaner, more declarative consumer API without callbacks threading through JSX.
Context is the modern way, but React.Children.map with React.cloneElement was the original approach — it cloned each child and injected extra props. It works, but it breaks with fragments and non-element children. Context handles all of that cleanly, so just use Context.
For typical UI components — tabs, accordions, dialogs — no, it won't matter. If you have hundreds of items reading from the same context and a frequently updating value, split into two contexts: one for stable callbacks and one for volatile state. That limits re-renders to only the components that actually need the changing value.
Yes, and it's a great combination. Radix gives you accessible, unstyled compound components as primitives; you wrap them with your own Context layer to add design tokens, themes, or extended state. Empire UI does exactly this — Radix handles a11y wiring, Empire's context layer handles visual style.