Compound Components Pattern
Compound components are a set of components that work together as a family to express a single cohesive concept. Think of how the browser's native <select> and <option> elements work — you never use <option> outside a <select>, and <select> is useless without <option> children. That relationship is exactly what the pattern models in React.
Popular libraries like Radix UI, Headless UI, and Reach UI are built almost entirely on this pattern. A <Dialog> compound component exposes <Dialog.Trigger>, <Dialog.Content>, <Dialog.Title>, and <Dialog.Close> — each a separate component, yet all sharing the same internal open/closed state through React Context.
The Problem It Solves
Without compound components you are forced to push every configuration detail through a single root component via props:
// ✗ Prop-drilling / configuration-object approach — inflexible
<Tabs
tabs={[
{ id: 'one', label: 'Tab One', content: <Panel1 /> },
{ id: 'two', label: 'Tab Two', content: <Panel2 /> },
]}
defaultTab="one"
onTabChange={handleChange}
/>
// ✓ Compound component approach — expressive and flexible
<Tabs defaultTab="one" onChange={handleChange}>
<Tabs.List>
<Tabs.Tab id="one">Tab One</Tabs.Tab>
<Tabs.Tab id="two">Tab Two</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="one"><Panel1 /></Tabs.Panel>
<Tabs.Panel id="two"><Panel2 /></Tabs.Panel>
</Tabs>Building a Tabs Component
The implementation uses a React Context to share the active tab ID and the setter between all compound parts without any prop-drilling:
import React, { createContext, useContext, useState } from 'react'
// 1. Create a Context for shared state
const TabsContext = createContext(null)
// 2. Custom hook to read the context (with a guard)
function useTabsContext() {
const ctx = useContext(TabsContext)
if (!ctx) {
throw new Error('Tabs compound components must be used inside <Tabs>')
}
return ctx
}
// 3. Root component — owns state, provides context
function Tabs({ children, defaultTab, onChange }) {
const [activeTab, setActiveTab] = useState(defaultTab)
function handleTabChange(id) {
setActiveTab(id)
onChange?.(id)
}
return (
<TabsContext.Provider value={{ activeTab, setActiveTab: handleTabChange }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
)
}
// 4. Sub-components — they read from context, not from props passed by the parent
function TabList({ children }) {
return <div role="tablist" className="tabs__list">{children}</div>
}
function Tab({ id, children }) {
const { activeTab, setActiveTab } = useTabsContext()
const isActive = activeTab === id
return (
<button
role="tab"
aria-selected={isActive}
aria-controls={`panel-${id}`}
onClick={() => setActiveTab(id)}
className={`tabs__tab ${isActive ? 'tabs__tab--active' : ''}`}
>
{children}
</button>
)
}
function TabPanel({ id, children }) {
const { activeTab } = useTabsContext()
if (activeTab !== id) return null
return (
<div role="tabpanel" id={`panel-${id}`} className="tabs__panel">
{children}
</div>
)
}
// 5. Attach sub-components as properties of the root — the "dot notation" API
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
export default TabsUsing the Component
import Tabs from './Tabs'
function App() {
return (
<Tabs defaultTab="profile" onChange={(id) => console.log('switched to', id)}>
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="settings">Settings</Tabs.Tab>
<Tabs.Tab id="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile">
<h2>Your Profile</h2>
<p>Edit your personal information here.</p>
</Tabs.Panel>
<Tabs.Panel id="settings">
<h2>Settings</h2>
<p>Configure your preferences.</p>
</Tabs.Panel>
<Tabs.Panel id="billing">
<h2>Billing</h2>
<p>Manage your subscription.</p>
</Tabs.Panel>
</Tabs>
)
}Flexible Composition
The real power becomes visible when callers need to customize layout. Since the compound components are just regular React components, you can arrange them freely:
// Tabs on the side instead of the top
<Tabs defaultTab="a">
<div style={{ display: 'flex' }}>
{/* Tab list on the left */}
<Tabs.List style={{ flexDirection: 'column' }}>
<Tabs.Tab id="a">Section A</Tabs.Tab>
<Tabs.Tab id="b">Section B</Tabs.Tab>
</Tabs.List>
{/* Panels on the right */}
<div style={{ flex: 1 }}>
<Tabs.Panel id="a"><p>Content A</p></Tabs.Panel>
<Tabs.Panel id="b"><p>Content B</p></Tabs.Panel>
</div>
</div>
</Tabs>Alternative: React.Children.map Variation
Before Context was common, compound components used React.Children.map and React.cloneElement to inject props into children. This approach still works but is less flexible (it only reaches direct children, not deeply nested ones):
// Older pattern — injects props directly into children via cloneElement
function TabsLegacy({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab)
const enhancedChildren = React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child
return React.cloneElement(child, { activeTab, setActiveTab })
})
return <div>{enhancedChildren}</div>
}
// Limitation: only direct children receive activeTab and setActiveTab.
// Deeply nested <Tab> components don't get them unless you repeat the mapping.
// Context-based approach does not have this limitation.Benefits of the Pattern
Declarative API — the JSX structure mirrors the visual structure; it reads like a document
Flexible composition — callers control layout by arranging sub-components freely
Implicit state sharing — sub-components access shared state through context, not through deeply-threaded props
Extensible — adding a new sub-component (e.g.,
Tabs.Badge) does not break existing usagesDiscoverable — dot notation groups related components in autocomplete, making the API self-documenting
Real Libraries Using This Pattern
Radix UI —
Dialog,Popover,Select,Accordionall use compound componentsHeadless UI —
Menu,Listbox,Combobox,DisclosureReach UI —
Tabs,Accordion,ComboboxReact Bootstrap —
Accordion,Nav,DropdownChakra UI —
Tabs,Accordion,Menu