Difficulty: Advanced
What is the Compound Components pattern in React? When would you use it?
The Compound Components pattern is one of the most elegant patterns in React, and once you understand it, you will start seeing it everywhere -- in UI libraries like Radix, Headless UI, Reach UI, and even in plain HTML (think about how select and option work together).
Let me explain the problem it solves first. Imagine you are building a Tabs component. The naive approach is to create one big component that takes everything as props: a list of tab labels, a list of tab contents, the active tab index, styling options, maybe an onChange callback. Something like <Tabs items={[{label: 'Tab 1', content: <Stuff />}, {label: 'Tab 2', content: <MoreStuff />}]} activeIndex={0} onChange={handleChange} />. This works, but it is rigid. What if someone wants to add an icon to one tab label? Or put a badge on another? Or add a divider between two specific tabs? Every new customization means another prop, and pretty soon your component has 30 props and a documentation page nobody reads.
The Compound Components pattern flips this on its head. Instead of one monolithic component, you provide a set of smaller components that work together. The parent component manages shared state (like which tab is active), and the child components read from that state via React Context. The consumer arranges the child components however they want.
So instead of that prop-heavy single component, you get something like: <Tabs defaultActive="tab1"> <Tabs.List> <Tabs.Tab id="tab1">Profile</Tabs.Tab> <Tabs.Tab id="tab2"><Icon /> Settings</Tabs.Tab> </Tabs.List> <Tabs.Panel id="tab1">Profile content here</Tabs.Panel> <Tabs.Panel id="tab2">Settings content here</Tabs.Panel> </Tabs>
See how flexible this is? Want an icon on one tab? Just put it there. Want a divider? Drop a div between two Tabs.Tab components. Want to reorder panels? Move them around. The consumer has full control over the rendering, while the Tabs parent handles all the state logic.
The key mechanism that makes this work is React Context. The parent Tabs component creates a context and provides the current active tab and a toggle function. Each Tabs.Tab component consumes that context to know if it is active and to switch tabs when clicked. Each Tabs.Panel consumes the context to know if it should show or hide. The sub-components are completely decoupled from each other -- they only talk through the shared context.
The sub-components are typically attached as static properties on the parent component (Tabs.Tab, Tabs.Panel, Accordion.Item, etc.). This is just a namespacing convention -- it groups related components together and makes the API discoverable. When someone types Tabs. in their editor, autocomplete shows all available sub-components.
This pattern is called inversion of control, which is a fancy way of saying: the library provides the behavior, the consumer controls the rendering. It is the same principle behind headless UI libraries -- they give you all the logic (keyboard navigation, ARIA attributes, state management) without dictating what the HTML or styles look like.
Real-world examples where this pattern shines: - Accordion with items that can contain anything (text, images, nested forms) - Dropdown menus where items might be links, buttons, or custom components - Select components with grouped options, search, and multi-select - Dialog/Modal with customizable header, body, and footer sections - Form components where fields can be arranged in any layout
One thing to watch out for: compound components add some indirection. A developer reading the code needs to understand that Tabs.Tab and Tabs.Panel are connected through context even though there is no visible prop linking them. Good documentation and clear naming help a lot. Also, if someone uses a sub-component outside the parent context, they will get a confusing error. A nice touch is to add a helpful error message: if useContext returns null, throw an error saying "Tabs.Tab must be used within a Tabs component."
The alternative to compound components is the config-prop approach (passing everything as objects to a single component). Config-props are easier to understand for simple use cases but become unmanageable as flexibility requirements grow. Compound components scale better because adding a new feature is just adding a new sub-component, not adding another prop to an already crowded API.
If you have used libraries like Radix UI, you have already used compound components. Their Dialog, DropdownMenu, Tooltip, and other primitives all follow this exact pattern. Understanding the pattern helps you both use these libraries effectively and build your own reusable components.
import { createContext, useContext, useState } from 'react';
const AccordionContext = createContext(null);
function Accordion({ children, defaultOpen = null }) {
const [openItem, setOpenItem] = useState(defaultOpen);
const toggle = (id) => setOpenItem(prev => prev === id ? null : id);
return (
<AccordionContext.Provider value={{ openItem, toggle }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function AccordionItem({ id, children }) {
return <div className="accordion-item" data-id={id}>{children}</div>;
}
function AccordionHeader({ id, children }) {
const { openItem, toggle } = useContext(AccordionContext);
return (
<button onClick={() => toggle(id)} aria-expanded={openItem === id}>
{children}
<span>{openItem === id ? '▲' : '▼'}</span>
</button>
);
}
function AccordionPanel({ id, children }) {
const { openItem } = useContext(AccordionContext);
if (openItem !== id) return null;
return <div role="region">{children}</div>;
}
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Panel = AccordionPanel;
// Usage - clean, flexible API
function FAQ() {
return (
<Accordion defaultOpen="q1">
<Accordion.Item id="q1">
<Accordion.Header id="q1">What is React?</Accordion.Header>
<Accordion.Panel id="q1">A JavaScript library...</Accordion.Panel>
</Accordion.Item>
<Accordion.Item id="q2">
<Accordion.Header id="q2">Why use hooks?</Accordion.Header>
<Accordion.Panel id="q2">They simplify state...</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
Context provides shared state. Sub-components access it via useContext. Users control layout and ordering - the parent just manages state.
Compound Components, Context, Component API, Flexible UI