When building component libraries or design systems, flexibility and developer ergonomics are key. You want developers to compose your components like Lego blocks, without worrying about internal state wiring.
That’s where Compound Components come in.
What Are Compound Components?
Compound Components are a React design pattern where:
- A Parent Component (Root) holds shared state or behavior.
- Child Components consume context from the parent, without needing to pass props manually.
It allows developers to compose UIs declaratively, while the library handles the interactions under the hood.
Real-World Examples
Library | Compound Components API |
---|---|
Radix UI | <Tabs.Root> <Tabs.List> <Tabs.Trigger> <Tabs.Content> </Tabs.Root> |
React Aria Components | <DialogTrigger> <Dialog> <DialogContent> </DialogTrigger> |
Headless UI (Tailwind) | <Menu> <MenuButton> <MenuItems> </Menu> |
In all these cases, developers don’t manage state explicitly — the parent component does.
Why Compound Components?
Benefit | Why It Matters |
---|---|
Declarative APIs | Developers compose UIs by nesting components |
State & Behavior Encapsulation | Parent handles logic; children stay dumb |
Flexible Composition | Devs can reorder or style components freely |
Avoid Prop Drilling | No need to pass props down manually |
Example: Building a Tabs Compound Component
1. Tabs Context
const TabsContext = createContext<{ value: string; setValue: (val: string) => void } | undefined>(undefined);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) throw new Error('Tabs components must be used within <Tabs.Root>');
return context;
}
2. Tabs.Root (Parent)
function TabsRoot({ children, defaultValue }: { children: ReactNode; defaultValue: string }) {
const [value, setValue] = useState(defaultValue);
return (
<TabsContext.Provider value={{ value, setValue }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
3. Tabs.Trigger (Child)
function TabsTrigger({ value: triggerValue, children }: { value: string; children: ReactNode }) {
const { value, setValue } = useTabsContext();
const isActive = value === triggerValue;
return (
<button onClick={() => setValue(triggerValue)} aria-selected={isActive}>
{children}
</button>
);
}
4. Tabs.Content (Child)
function TabsContent({ value: contentValue, children }: { value: string; children: ReactNode }) {
const { value } = useTabsContext();
return value === contentValue ? <div>{children}</div> : null;
}
5. Usage
<TabsRoot defaultValue="account">
<div>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</div>
<TabsContent value="account">Account Content</TabsContent>
<TabsContent value="settings">Settings Content</TabsContent>
</TabsRoot>
Key Points:
- Developers compose structure.
- The library handles behavior wiring via Context.
- Developers don’t need to pass down
onClick
,isActive
, etc.
When Should You Use Compound Components?
Use Case | Compound Components? |
---|---|
Complex interactions across parts | Yes |
State shared among siblings | Yes |
Simple isolated UI elements | No |
Best Practices
- Use Context API to share state across compounds.
- Ensure child components throw helpful errors if misused outside Root.
- Document clearly how developers should compose them.
- Provide sensible defaults (e.g., default active tab).
Conclusion
Compound Components are a superpower for design systems. They provide an intuitive API for developers, while keeping state and behavior encapsulated under the hood. It leads to better DX, more flexibility, and cleaner components.
Mastering this pattern will elevate your component library architecture.
Do you already use Compound Components? Share your experience or questions below!
Top comments (0)