React Compound Components: The Context Pattern That Solves What Children Alone Can't
Children composition handles rendering decisions, but when sibling components need shared state, you need compound components with Context. Here's how to build them correctly.
Children composition gets you far. Further than most codebases go.
But it hits a wall the moment two sibling components need the same piece of state.
Say you've decomposed a Select into Select.Trigger, Select.Options, and Select.Option. Clean API, no prop explosion. Then you realize Select.Trigger needs to display the current value. Select.Option needs to know what's selected to render a checkmark. Select.Options needs to know whether it's open.
And suddenly you're doing this:
// ❌ manually threading state into every child
function ProductSelect({ value, onChange }: SelectProps) {
const [open, setOpen] = useState(false);
return (
<Select>
<Select.Trigger
value={value}
open={open}
onClick={() => setOpen((o) => !o)}
/>
<Select.Options open={open} value={value} onClose={() => setOpen(false)}>
{options.map((opt) => (
<Select.Option
key={opt.value}
option={opt}
selected={opt.value === value}
onChange={onChange}
/>
))}
</Select.Options>
</Select>
);
}Children composition solved the rendering problem. It didn't solve the state problem. selected, open, and onChange are now threading through every component, and the consumer is managing state that shouldn't be their responsibility.
This is exactly what compound components with Context are for.
The Mental Model Shift
Most developers associate Context with app-level concerns: theme, authentication, localization. State that many unrelated parts of the tree need to read.
But Context doesn't have to be global. A component can create its own context, provide it only to its own children, and let those children read shared state without a prop in sight. The consumer never touches the state directly.
That isolated context is the backbone of compound components.
Building the Select
First, define the context shape:
import { createContext, useContext, useState, useMemo, ReactNode } from "react";
type SelectContextValue = {
value: string;
onChange: (value: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
};
const SelectContext = createContext<SelectContextValue | null>(null);Start the default as null, not an empty object with fallback values. You'll see why in a moment.
The parent component owns the state and provides it via context:
type SelectProps = {
children: ReactNode;
value: string;
onChange: (value: string) => void;
};
function Select({ children, value, onChange }: SelectProps) {
const [open, setOpen] = useState(false);
const context = useMemo(
() => ({ value, onChange, open, setOpen }),
[value, onChange, open],
);
return (
<SelectContext.Provider value={context}>{children}</SelectContext.Provider>
);
}The useMemo matters. Without it, every render of Select creates a new context object. A new object means a new reference, which triggers a re-render in every child that consumes the context, even when nothing actually changed. Easy to forget. Expensive to diagnose later.
Now the custom hook for consuming the context:
function useSelect() {
const context = useContext(SelectContext);
if (!context) {
throw new Error("useSelect must be used within a <Select> component");
}
return context;
}This is why you set the default to null. If a developer drops <Select.Trigger> outside a <Select> somewhere, they get a clear, immediate error message instead of a silent undefined access three components deep. I've saved myself real debugging time with this pattern.
Now the child components:
function SelectTrigger({
placeholder = "Select...",
}: {
placeholder?: string;
}) {
const { value, open, setOpen } = useSelect();
return (
<button
type="button"
className={`select-trigger ${open ? "select-trigger--open" : ""}`}
onClick={() => setOpen(!open)}
aria-haspopup="listbox"
aria-expanded={open}
>
{value || placeholder}
</button>
);
}
function SelectOptions({ children }: { children: ReactNode }) {
const { open } = useSelect();
if (!open) return null;
return (
<ul className="select-options" role="listbox">
{children}
</ul>
);
}
function SelectOption({
value,
children,
}: {
value: string;
children: ReactNode;
}) {
const { value: selectedValue, onChange, setOpen } = useSelect();
const isSelected = value === selectedValue;
return (
<li
role="option"
aria-selected={isSelected}
className={`select-option ${isSelected ? "select-option--selected" : ""}`}
onClick={() => {
onChange(value);
setOpen(false);
}}
>
{children}
{isSelected && <CheckIcon className="select-option-check" aria-hidden />}
</li>
);
}Attach them as static properties:
Select.Trigger = SelectTrigger;
Select.Options = SelectOptions;
Select.Option = SelectOption;The consumer API:
// ✅ open/close state is internal — consumer passes value and onChange, nothing else
<Select value={framework} onChange={setFramework}>
<Select.Trigger placeholder="Choose a framework" />
<Select.Options>
<Select.Option value="react">React</Select.Option>
<Select.Option value="vue">Vue</Select.Option>
<Select.Option value="svelte">Svelte</Select.Option>
</Select.Options>
</Select>Select.Trigger knows the current value without receiving a prop. Select.Option knows whether it's selected without receiving a prop. The consumer doesn't manage open at all. The state lives in context, and each child reads exactly what it needs.
TypeScript: Typing the Sub-Components
TypeScript needs a nudge for static properties on components. The cleanest approach:
type SelectComponent = {
(props: SelectProps): JSX.Element;
Trigger: typeof SelectTrigger;
Options: typeof SelectOptions;
Option: typeof SelectOption;
};
const Select = function Select({ children, value, onChange }: SelectProps) {
const [open, setOpen] = useState(false);
const context = useMemo(
() => ({ value, onChange, open, setOpen }),
[value, onChange, open],
);
return (
<SelectContext.Provider value={context}>{children}</SelectContext.Provider>
);
} as SelectComponent;
Select.Trigger = SelectTrigger;
Select.Options = SelectOptions;
Select.Option = SelectOption;With this, you get full autocompletion on Select.Trigger, Select.Options, and Select.Option without any casting or workarounds.
The Same Pattern: Accordion
The compound component pattern isn't specific to Select. Any set of sibling components that share state is a candidate.
An Accordion is a clean example: multiple panels, exactly one open at a time. Without context you're either threading the active panel ID into every Panel, or making the consumer manage it (which defeats the point of having an Accordion component at all).
// ❌ consumer owns the state and threads it into every AccordionItem
function ProductFAQ() {
const [activePanel, setActivePanel] = useState<string | null>("shipping");
return (
<div className="accordion">
<AccordionItem
id="shipping"
title="Shipping policy"
activePanel={activePanel}
setActivePanel={setActivePanel}
>
Orders ship within 2 business days. Free shipping on orders over $50.
</AccordionItem>
<AccordionItem
id="returns"
title="Return policy"
activePanel={activePanel}
setActivePanel={setActivePanel}
>
Returns accepted within 30 days with original packaging.
</AccordionItem>
<AccordionItem
id="sizing"
title="Size guide"
activePanel={activePanel}
setActivePanel={setActivePanel}
>
<SizeGuideTable />
</AccordionItem>
</div>
);
}activePanel and setActivePanel repeat on every item. Add a fourth panel and you thread them again. The consumer is managing internal accordion state that should never leave the component.
type AccordionContextValue = {
activePanel: string | null;
setActivePanel: (id: string | null) => void;
};
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) throw new Error("useAccordion must be used within <Accordion>");
return context;
}
function Accordion({
children,
defaultOpen = null,
}: {
children: ReactNode;
defaultOpen?: string | null;
}) {
const [activePanel, setActivePanel] = useState<string | null>(defaultOpen);
const context = useMemo(
() => ({ activePanel, setActivePanel }),
[activePanel],
);
return (
<AccordionContext.Provider value={context}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function AccordionItem({
id,
title,
children,
}: {
id: string;
title: ReactNode;
children: ReactNode;
}) {
const { activePanel, setActivePanel } = useAccordion();
const isOpen = activePanel === id;
return (
<div className="accordion-item">
<button
className="accordion-trigger"
onClick={() => setActivePanel(isOpen ? null : id)}
aria-expanded={isOpen}
aria-controls={`panel-${id}`}
>
{title}
<ChevronIcon className={isOpen ? "rotate-180" : ""} aria-hidden />
</button>
{isOpen && (
<div id={`panel-${id}`} className="accordion-panel" role="region">
{children}
</div>
)}
</div>
);
}
Accordion.Item = AccordionItem;Consumer:
<Accordion defaultOpen="shipping">
<Accordion.Item id="shipping" title="Shipping policy">
Orders ship within 2 business days. Free shipping on orders over $50.
</Accordion.Item>
<Accordion.Item id="returns" title="Return policy">
Returns accepted within 30 days with original packaging.
</Accordion.Item>
<Accordion.Item id="sizing" title="Size guide">
<SizeGuideTable />
</Accordion.Item>
</Accordion>Each AccordionItem reads its own id against activePanel in context to know whether it's open. The parent manages one piece of state. The consumer manages nothing.
Tabs: ARIA Wiring and the Full Pattern
The Accordion toggles panels open and closed. Tabs switches between them exclusively, and it needs a specific set of ARIA roles to be accessible. This is where compound components pay off in a different way: the accessibility wiring lives inside the components, invisible to the consumer.
// ❌ consumer owns activeTab and passes it into every trigger and panel
function DashboardTabs() {
const [activeTab, setActiveTab] = useState("overview");
return (
<div className="tabs">
<div role="tablist">
<TabsTrigger
id="overview"
activeTab={activeTab}
setActiveTab={setActiveTab}
>
Overview
</TabsTrigger>
<TabsTrigger
id="analytics"
activeTab={activeTab}
setActiveTab={setActiveTab}
>
Analytics
</TabsTrigger>
<TabsTrigger
id="settings"
activeTab={activeTab}
setActiveTab={setActiveTab}
>
Settings
</TabsTrigger>
</div>
<TabsPanel id="overview" activeTab={activeTab}>
<DashboardOverview />
</TabsPanel>
<TabsPanel id="analytics" activeTab={activeTab}>
<AnalyticsChart dateRange={range} />
</TabsPanel>
<TabsPanel id="settings" activeTab={activeTab}>
<SettingsForm onSave={handleSave} />
</TabsPanel>
</div>
);
}activeTab goes into every trigger and every panel. The ARIA wiring (role, aria-selected, aria-controls, tabIndex) would have to live here too — or get dropped entirely. Neither is acceptable.
type TabsContextValue = {
activeTab: string;
setActiveTab: (id: string) => void;
};
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const context = useContext(TabsContext);
if (!context) throw new Error("useTabs must be used within <Tabs>");
return context;
}
function Tabs({
children,
defaultTab,
}: {
children: ReactNode;
defaultTab: string;
}) {
const [activeTab, setActiveTab] = useState(defaultTab);
const context = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]);
return (
<TabsContext.Provider value={context}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabsList({ children }: { children: ReactNode }) {
return (
<div role="tablist" className="tabs-list">
{children}
</div>
);
}
function TabsTrigger({ id, children }: { id: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === id;
return (
<button
role="tab"
id={`tab-${id}`}
aria-selected={isActive}
aria-controls={`panel-${id}`}
tabIndex={isActive ? 0 : -1}
className={`tabs-trigger ${isActive ? "tabs-trigger--active" : ""}`}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
function TabsPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return (
<div
role="tabpanel"
id={`panel-${id}`}
aria-labelledby={`tab-${id}`}
className="tabs-panel"
>
{children}
</div>
);
}
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Panel = TabsPanel;Consumer:
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Trigger id="overview">Overview</Tabs.Trigger>
<Tabs.Trigger id="analytics">Analytics</Tabs.Trigger>
<Tabs.Trigger id="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel id="overview">
<DashboardOverview />
</Tabs.Panel>
<Tabs.Panel id="analytics">
<AnalyticsChart dateRange={range} />
</Tabs.Panel>
<Tabs.Panel id="settings">
<SettingsForm onSave={handleSave} />
</Tabs.Panel>
</Tabs>Look at what the consumer doesn't touch: role="tablist", aria-selected, aria-controls, aria-labelledby, tabIndex. All of it is wired inside the components. The consumer writes clean JSX. The ARIA spec is satisfied without them knowing it exists. This is the real argument for compound components in a design system: you encode the spec once, and every consumer gets it for free.
Context Splitting for Performance
All the examples so far put everything into one context: state, setters, config. That works for most components. But as the context value grows, a subtle performance problem appears.
Every component that calls useTabs() re-renders whenever any value in the context changes. If Tabs has an orientation prop (static after mount) alongside activeTab (changes on every click), a component that only reads orientation still re-renders on every tab switch.
The fix is to split the context:
// changes on every interaction
type TabsStateContextValue = {
activeTab: string;
setActiveTab: (id: string) => void;
};
// static after mount
type TabsConfigContextValue = {
orientation: "horizontal" | "vertical";
};
const TabsStateCtx = createContext<TabsStateContextValue | null>(null);
const TabsConfigCtx = createContext<TabsConfigContextValue | null>(null);
function useTabsState() {
const ctx = useContext(TabsStateCtx);
if (!ctx) throw new Error("useTabsState must be used within <Tabs>");
return ctx;
}
function useTabsConfig() {
const ctx = useContext(TabsConfigCtx);
if (!ctx) throw new Error("useTabsConfig must be used within <Tabs>");
return ctx;
}
function Tabs({
children,
defaultTab,
orientation = "horizontal",
}: {
children: ReactNode;
defaultTab: string;
orientation?: "horizontal" | "vertical";
}) {
const [activeTab, setActiveTab] = useState(defaultTab);
const state = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]);
const config = useMemo(() => ({ orientation }), [orientation]);
return (
<TabsConfigCtx.Provider value={config}>
<TabsStateCtx.Provider value={state}>
<div className={`tabs tabs--${orientation}`}>{children}</div>
</TabsStateCtx.Provider>
</TabsConfigCtx.Provider>
);
}Now TabsTrigger subscribes to TabsStateCtx and re-renders on tab changes. A component that only needs orientation subscribes to TabsConfigCtx — it re-renders when orientation changes, which is essentially never.
For most components this is premature. A Select with four options doesn't need it. For high-frequency interactions, large subtrees, or design system components used everywhere, it's worth the extra context.
What Not to Do: cloneElement
Before Context was the standard for this, some libraries used React.cloneElement to inject props into children:
// ❌ cloneElement: breaks with Fragment wrappers, impossible to type correctly
function Select({ children, value, onChange }) {
return (
<div className="select">
{React.Children.map(children, (child) =>
React.cloneElement(child, { value, onChange }),
)}
</div>
);
}This approach is fragile in every direction. It breaks silently when children are wrapped in a Fragment. It can't reach nested children. TypeScript can't infer the injected props. It's not how React was designed to share state downward. Context solves the same problem and does it correctly.
When to Reach for This Pattern
Compound components with Context are the right fit when:
- A set of related components shares state that consumers shouldn't manage manually
- You're building reusable components: a design system, a shared component library, or product components used across multiple pages
- You'd otherwise be prop drilling the same values into three or more siblings
It's overkill when:
- Only one child needs the state. Just pass a prop.
- The components live in one place and won't be reused. Inline state is fine.
- The state is genuinely global. Use a store, not a component-scoped context.
The pattern has a real cost: it's more indirection than a straightforward component. A developer reading Select.Trigger for the first time has to follow the context to understand where value comes from. That's worth it when the component is used in twenty places. It's probably not worth it for a one-off form.
The pattern draws a clear boundary. The parent owns the state. The children own the rendering. The consumer passes value and onChange to the root and gets a self-managing, composed component back.
Context is the wire that connects the pieces without exposing implementation details to the outside world. It's why every serious UI library (Radix UI, Headless UI, React Aria) is built on exactly this foundation. They're not using a special React API. They're using this pattern, consistently, across every component they ship.