The Compound Components Pattern: Building Flexible, Declarative APIs in React
When building reusable UI widgets in design systems—such as dropdown menus, tabs, accordions, modals, and multi-select comboboxes—component authors face a critical API design dilemma.
In early design iterations, developers often create a monolithic configuration component:
// ⚠️ THE MONOLITHIC "PROP APOCALYPSE" ANTI-PATTERN:
<Select
options={items}
selectedValue={selected}
onChange={setSelected}
placeholder="Choose an option"
renderItem={(item) => <CustomBadge item={item} />}
renderHeader={() => <SearchHeader />}
showSearch={true}
dropdownPosition="bottom-left"
closeOnSelect={true}
emptyStateText="No results found"
/>
While well-intentioned, this monolithic approach quickly collapses under its own weight. Every time a consumer needs a slight visual tweak—such as placing an icon next to an option, grouping items into sections, or injecting custom dividers—the author must add yet another prop. Over time, the component accumulates dozens of props, becoming brittle, difficult to maintain, and nearly impossible to test.
The architectural antidote to this complexity is the Compound Components Pattern (React documentation). Popularized by foundational libraries like Radix UI, Headless UI, and Shadcn, compound components leverage React Context to share implicit state while handing full layout control back to the consumer.
In this deep dive, we explore how compound components achieve Inversion of Control, how to coordinate implicit state in TypeScript, and how to build production-grade, accessible component primitives.
What are Compound Components?
The Compound Components pattern models an API after native HTML elements like <select> and <option>:
<select name="fruits">
<option value="apple">Apple</option>
<option value="orange">Orange</option>
</select>
Notice that you do not pass an array of strings to <select> as a prop. Instead, <select> and <option> cooperate: <select> manages the selected state, while <option> renders the children.
In React, compound components allow developers to structure complex components declaratively:
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">
<AccountSettings />
</Tabs.Content>
<Tabs.Content value="password">
<PasswordSettings />
</Tabs.Content>
</Tabs>
Core Advantage: Inversion of Control
The primary benefit of compound components is Inversion of Control (IoC):
- Layout Freedom: The consumer dictates where elements sit in the DOM. Want to put the tab list on the side? Want to inject an announcement banner between the tabs and the content? Simply adjust the JSX markup.
- Zero Prop Drilling: Subcomponents communicate seamlessly behind the scenes without requiring props to be threaded through intermediate layout wrappers.
- Clean Composition: Adding tooltips, icons, or badges to individual triggers requires zero new props on the parent container.
Implementation Blueprint: Building an Accessible Accordion in TypeScript
Let us build an expandable Accordion component featuring implicit state coordination and keyboard accessibility (W3C ARIA Accordion Pattern):
1. Establish the Internal Context
'use client';
import React, { createContext, useContext, useState, useId } from 'react';
interface AccordionContextType {
openItem: string | null;
toggleItem: (value: string) => void;
}
const AccordionContext = createContext<AccordionContextType | null>(null);
function useAccordionContext(componentName: string) {
const context = useContext(AccordionContext);
if (!context) {
throw new Error(`${componentName} must be used within an <Accordion> parent component.`);
}
return context;
}
2. Implement the Root Container
interface AccordionProps {
children: React.ReactNode;
defaultValue?: string | null;
}
export function Accordion({ children, defaultValue = null }: AccordionProps) {
const [openItem, setOpenItem] = useState<string | null>(defaultValue);
function toggleItem(value: string) {
setOpenItem(prev => (prev === value ? null : value));
}
return (
<AccordionContext.Provider value={{ openItem, toggleItem }}>
<div className="accordion-root">{children}</div>
</AccordionContext.Provider>
);
}
3. Implement the Item Context & Subcomponents
Each accordion item coordinates its own unique identifier and accessibility IDs:
interface ItemContextType {
value: string;
triggerId: string;
contentId: string;
}
const ItemContext = createContext<ItemContextType | null>(null);
export function AccordionItem({ value, children }: { value: string; children: React.ReactNode }) {
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const contentId = `${baseId}-content`;
return (
<ItemContext.Provider value={{ value, triggerId, contentId }}>
<div className="accordion-item">{children}</div>
</ItemContext.Provider>
);
}
export function AccordionTrigger({ children }: { children: React.ReactNode }) {
const { openItem, toggleItem } = useAccordionContext('AccordionTrigger');
const item = useContext(ItemContext);
if (!item) throw new Error('AccordionTrigger must be used inside AccordionItem');
const isOpen = openItem === item.value;
return (
<button
id={item.triggerId}
aria-expanded={isOpen}
aria-controls={item.contentId}
onClick={() => toggleItem(item.value)}
className="accordion-trigger"
>
<span>{children}</span>
<span className="chevron">{isOpen ? '▲' : '▼'}</span>
</button>
);
}
export function AccordionContent({ children }: { children: React.ReactNode }) {
const { openItem } = useAccordionContext('AccordionContent');
const item = useContext(ItemContext);
if (!item) throw new Error('AccordionContent must be used inside AccordionItem');
const isOpen = openItem === item.value;
if (!isOpen) return null;
return (
<div
id={item.contentId}
role="region"
aria-labelledby={item.triggerId}
className="accordion-content"
>
{children}
</div>
);
}
// 4. Attach subcomponents for clean dot-notation export
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
Consuming the Component
Notice how natural and expressive consumption becomes:
export function FAQSection() {
return (
<Accordion defaultValue="pricing">
<Accordion.Item value="pricing">
<Accordion.Trigger>What are your pricing plans?</Accordion.Trigger>
<Accordion.Content>
We offer flexible monthly and annual subscriptions with volume discounts.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="refunds">
<Accordion.Trigger>What is your refund policy?</Accordion.Trigger>
<Accordion.Content>
All plans include a full 30-day money-back guarantee.
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}
Trade-offs and Best Practices
1. Guard Against Misuse
Notice the useAccordionContext validation check. If a developer accidentally renders <Accordion.Trigger> outside of an <Accordion>, throwing a clear, descriptive error saves hours of debugging time.
2. Context vs. cloneElement
Historically, developers created compound components by iterating over props.children with React.Children.map and calling React.cloneElement(child, { isOpen }).
Avoid this legacy pattern today. cloneElement only works on direct children, breaks if children are wrapped inside layout <div>s, and cannot pierce component boundaries. React Context is significantly more resilient and handles deeply nested markup gracefully.
3. Namespacing vs Named Exports
Attaching subcomponents to the parent function object (Accordion.Trigger = ...) provides clean developer auto-completion. In tree-shaking environments, you can simultaneously export named subcomponents (export { AccordionTrigger }) to ensure compatibility with all bundlers.
Conclusion
The Compound Components pattern is one of React’s most enduring and elegant architectural patterns. By leveraging React Context to share state implicitly while giving consumers full control over component structure, developers can build flexible, accessible, and maintenance-free design systems that scale effortlessly.