Compound Component Pattern
In this page:
What Makes Components 'Compound'
Compound components are multiple components meant to be used together as a set, like HTML's <select> and <option>. Instead of one component accepting a huge configuration prop, the API is broken into smaller pieces that communicate implicitly through shared context, giving the caller more layout freedom.
Note: A good sign you need compound components: a single component's prop list is growing long and awkward (items, renderItem, itemClassName, ...).
Warning: Compound components only work correctly when the pieces are rendered inside their intended parent — using Tabs.Tab outside <Tabs> has no shared state to connect to.
Example: What Makes Components 'Compound'
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const TabsContext = React.createContext();
function Tabs({ children, defaultTab }) {
const [active, setActive] = React.useState(defaultTab);
return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>;
}
function Tab({ id, children }) {
const { active, setActive } = React.useContext(TabsContext);
return <button onClick={() => setActive(id)} style={{fontWeight: active === id ? "bold" : "normal"}}>{children}</button>;
}
function App() {
return <Tabs defaultTab="a"><Tab id="a">Tab A</Tab><Tab id="b">Tab B</Tab></Tabs>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
Sharing State via Context Instead of Props
Rather than passing the active tab down through explicit props at every level, compound components store shared state in a Context provided by the parent. Any sub-component can then read that context directly, no matter how deeply it's nested inside the parent's children.
Note: Create the Context object outside the component definitions (at module scope) so it isn't recreated on every render.
Warning: If you forget to check for a missing context (e.g. Tab used without a Tabs parent), useContext returns undefined and destructuring it throws a confusing error.
Example: Sharing State via Context Instead of Props
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const PanelContext = React.createContext();
function Accordion({ children }) {
const [openIndex, setOpenIndex] = React.useState(null);
return <PanelContext.Provider value={{ openIndex, setOpenIndex }}>{children}</PanelContext.Provider>;
}
function Panel({ index, title, children }) {
const { openIndex, setOpenIndex } = React.useContext(PanelContext);
const isOpen = openIndex === index;
return <div><button onClick={() => setOpenIndex(isOpen ? null : index)}>{title}</button>{isOpen && <p>{children}</p>}</div>;
}
function App() {
return <Accordion><Panel index={0} title="Section 1">Content 1</Panel><Panel index={1} title="Section 2">Content 2</Panel></Accordion>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
Attaching Sub-Components for a Clean API
A common finishing touch is attaching the sub-components as properties of the main component, like Tabs.Tab = Tab. This lets consumers import just one name (Tabs) and access everything through it, making the related pieces obviously connected.
Note: This is purely an ergonomic/discoverability improvement — Tabs.Tab and a separately-exported Tab function behave identically at runtime.
Warning: Attaching sub-components after defining them (Tabs.Tab = Tab) must happen after both Tabs and Tab are declared, or you'll assign undefined.
Example: Attaching Sub-Components for a Clean API
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const TabsContext = React.createContext();
function Tabs({ children, defaultTab }) {
const [active, setActive] = React.useState(defaultTab);
return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>;
}
function Tab({ id, children }) {
const { active, setActive } = React.useContext(TabsContext);
return <button onClick={() => setActive(id)} style={{fontWeight: active === id ? "bold" : "normal"}}>{children}</button>;
}
Tabs.Tab = Tab;
function App() {
return <Tabs defaultTab="x"><Tabs.Tab id="x">X</Tabs.Tab><Tabs.Tab id="y">Y</Tabs.Tab></Tabs>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>
- Using compound components without setting up shared state via Context, forcing awkward prop-drilling between the pieces instead.
- Not exporting the sub-components as properties of the parent (like Tabs.Panel), making the API less discoverable.
- Allowing the sub-components to be used outside their parent, where they have no shared context and silently fail.
- Compound components are a set of components designed to work together, sharing implicit state via Context.
- The classic example is a Tabs component made of Tabs, Tabs.List, Tabs.Tab, and Tabs.Panel working together.
- This pattern gives the consumer flexible control over layout while the parent manages the shared state internally.
- It's commonly implemented using React Context to pass state down without manual prop drilling.
Available since React 16.3 (stable Context API) — earlier versions could approximate this with React.Children/cloneElement.
Chapter Quiz — Complete all 10 topics to unlock
0/10 topics done
Complete these topics first: