ReactCompound Components Pattern

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:

JSX
// ✗ 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:

JSX
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 Tabs
Using the Component

JSX
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:

JSX
// 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>
Note
This flexibility is impossible with the props-config approach. You would need to add a `layout="vertical"` prop, then handle it inside the component with a conditional render — adding complexity for every new layout variation. With compound components, the caller simply arranges the children however they like.
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):

JSX
// 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.
Warning
The React.Children.map approach breaks if the consumer wraps a Tab in a `<div>` or any other element. The Context-based approach works at any nesting depth, making it strongly preferred for modern compound components.
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 usages

  • Discoverable — dot notation groups related components in autocomplete, making the API self-documenting

Real Libraries Using This Pattern
  • Radix UIDialog, Popover, Select, Accordion all use compound components

  • Headless UIMenu, Listbox, Combobox, Disclosure

  • Reach UITabs, Accordion, Combobox

  • React BootstrapAccordion, Nav, Dropdown

  • Chakra UITabs, Accordion, Menu

Tip
When building a component library, always ask: "Could a consumer need to change the arrangement of parts?" If yes, compound components give them that freedom without you having to anticipate every layout variant upfront.