Compound Components
[!IMPORTANT] The Compound Components pattern allows you to build components that work together to form a complete UI unit, sharing state implicitly behind the scenes. Think of
<select>and<option>in HTML—they work together without you manually passing state between them.
The Problem: Prop Explosion
When building reusable components, you often start by passing everything as props. But as the component grows, you end up with a “God Component” that takes dozens of props just to handle different configurations.
// ❌ Hard to manage, hard to extend
<Tabs
items={[
{ label: 'Tab 1', content: 'Content 1' },
{ label: 'Tab 2', content: 'Content 2' }
]}
activeColor="blue"
onChange={index => console.log(index)}
defaultIndex={0}
/>
What if you want to add an icon to one tab? Or disable just one tab? Or put the tabs at the bottom? You’d need more props: renderTabLabel, tabPosition, disabledIndices, etc.
The Solution: Compound Components
Instead of one giant component, we create a set of components that work together. They share state (like “which tab is active”) implicitly using React Context.
// ✅ Flexible, declarative, easy to read
<Tabs defaultIndex={0} onChange={index => console.log(index)}>
<Tabs.List>
<Tabs.Tab index={0}>Tab 1</Tabs.Tab>
<Tabs.Tab index={1} disabled>Tab 2</Tabs.Tab>
<Tabs.Tab index={2}>Tab 3</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel index={0}>Content 1</Tabs.Panel>
<Tabs.Panel index={1}>Content 2</Tabs.Panel>
<Tabs.Panel index={2}>Content 3</Tabs.Panel>
</Tabs.Panels>
</Tabs>
This is the Compound Components pattern.
Interactive Demo: Tabs Builder
Explore how the Tabs component coordinates with its children (Tab and Panel) to manage the active state without you seeing it.
How It Works: The Context API
The magic behind compound components is React’s Context API. The parent component (Tabs) creates a context provider, and the child components (Tab, Panel) consume that context.
1. Create the Context
First, we define the shape of our context and create it.
import React, { createContext, useContext, useState, ReactNode } from 'react';
type TabsContextType = {
activeTab: number;
setActiveTab: (index: number) => void;
};
const TabsContext = createContext<TabsContextType | undefined>(undefined);
// Custom hook for consuming context safely
function useTabs() {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs components must be used within a <Tabs /> provider');
}
return context;
}
2. The Parent Component (Provider)
The parent component manages the state and provides it to its children.
interface TabsProps {
children: ReactNode;
defaultIndex?: number;
onChange?: (index: number) => void;
}
function Tabs({ children, defaultIndex = 0, onChange }: TabsProps) {
const [activeTab, setActiveTabState] = useState(defaultIndex);
const setActiveTab = (index: number) => {
setActiveTabState(index);
onChange?.(index);
};
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs-container">{children}</div>
</TabsContext.Provider>
);
}
3. The Child Components (Consumers)
The children use the useTabs hook to read and update the state.
interface TabProps {
index: number;
children: ReactNode;
disabled?: boolean;
}
function Tab({ index, children, disabled }: TabProps) {
const { activeTab, setActiveTab } = useTabs();
const isActive = activeTab === index;
return (
<button
className={`tab-btn ${isActive ? 'active' : ''}`}
onClick={() => !disabled && setActiveTab(index)}
disabled={disabled}
>
{children}
</button>
);
}
interface PanelProps {
index: number;
children: ReactNode;
}
function Panel({ index, children }: PanelProps) {
const { activeTab } = useTabs();
return activeTab === index ? <div className="tab-panel">{children}</div> : null;
}
4. Exposing as Compound Components
Finally, attach the sub-components to the main Tabs component for a clean API.
// Assign sub-components
Tabs.Tab = Tab;
Tabs.Panel = Panel;
export default Tabs;
Visualizing the Component Tree
Here is how the data flows in our Compound Component structure:
Flexible Compound Components
You can make your compound components even more flexible by not enforcing direct parent-child relationships. Using React.Children.map allows you to clone children and inject props, but using Context allows components to be deeply nested.
Example: Separating List and Panels
With Context, we can group tabs and panels separately:
<Tabs>
<div className="sidebar">
<Tabs.Tab index={0}>Profile</Tabs.Tab>
<Tabs.Tab index={1}>Settings</Tabs.Tab>
</div>
<div className="main-content">
<Tabs.Panel index={0}>Profile Page</Tabs.Panel>
<Tabs.Panel index={1}>Settings Page</Tabs.Panel>
</div>
</Tabs>
This works perfectly because Tab and Panel consume the context regardless of where they are in the DOM tree, as long as they are descendants of <Tabs>.
[!TIP] Use this pattern when you want to give the user of your component full control over the layout (HTML structure) while you control the logic and state.
Summary
- Compound Components share state implicitly between a parent and its children.
- Context API is the primary tool for implementing this pattern in modern React.
- It solves the “Prop Explosion” problem by avoiding passing configuration props deep down.
- It enables declarative and composable APIs.