React

The Compound Component Pattern in React

Learn how the compound component pattern lets you build flexible, composable UI components that share state without prop drilling or a complex API.

I kept running into the same problem when building UI components. A Select dropdown needed to communicate its selected value to the Option items inside it. A Tabs component needed to know which tab was active so each Tab and Panel could react accordingly. The obvious solution — passing everything as props — worked, but it produced components with eight or ten props and an API that was awkward to use.

The compound component pattern is a cleaner way to handle this. Instead of one big component that accepts everything, you break the UI into a set of smaller components that share state implicitly. The parent holds the state; the children access it through context. The consumer composes them however they need.

A concrete example: Tabs

Here's what the compound component pattern looks like from the outside:

<Tabs defaultValue="react">
  <Tabs.List>
    <Tabs.Trigger value="react">React</Tabs.Trigger>
    <Tabs.Trigger value="vue">Vue</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="react">React content here</Tabs.Panel>
  <Tabs.Panel value="vue">Vue content here</Tabs.Panel>
</Tabs>

No activeTab prop. No onTabChange callback threading through multiple components. The consumer just composes the pieces.

Building it

Start with a context to hold the shared state:

interface TabsContext {
  active: string;
  setActive: (value: string) => void;
}

const TabsContext = createContext<TabsContext | null>(null);

function useTabsContext() {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tabs components must be used inside <Tabs>");
  return ctx;
}

The useTabsContext guard is worth keeping. Without it, a Tabs.Panel rendered outside a Tabs wrapper produces a confusing runtime error about undefined values. With it, the error message tells you exactly what went wrong.

Next, the root Tabs component that owns the state:

interface TabsProps {
  defaultValue: string;
  children: React.ReactNode;
}

function Tabs({ defaultValue, children }: TabsProps) {
  const [active, setActive] = useState(defaultValue);

  return (
    <TabsContext.Provider value={{ active, setActive }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

Then the sub-components:

function TabsList({ children }: { children: React.ReactNode }) {
  return <div role="tablist">{children}</div>;
}

interface TabsTriggerProps {
  value: string;
  children: React.ReactNode;
}

function TabsTrigger({ value, children }: TabsTriggerProps) {
  const { active, setActive } = useTabsContext();
  return (
    <button
      role="tab"
      aria-selected={active === value}
      onClick={() => setActive(value)}
    >
      {children}
    </button>
  );
}

interface TabsPanelProps {
  value: string;
  children: React.ReactNode;
}

function TabsPanel({ value, children }: TabsPanelProps) {
  const { active } = useTabsContext();
  if (active !== value) return null;
  return <div role="tabpanel">{children}</div>;
}

Finally, attach the sub-components to the root using a namespace object:

Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Panel = TabsPanel;

TypeScript will complain about this without a type annotation on Tabs. The fix:

const Tabs = Object.assign(
  function Tabs({ defaultValue, children }: TabsProps) {
    const [active, setActive] = useState(defaultValue);
    return (
      <TabsContext.Provider value={{ active, setActive }}>
        <div className="tabs">{children}</div>
      </TabsContext.Provider>
    );
  },
  {
    List: TabsList,
    Trigger: TabsTrigger,
    Panel: TabsPanel,
  }
);

Now Tabs.List, Tabs.Trigger, and Tabs.Panel are all typed correctly.

Why this is better than prop-based alternatives

The most common alternative is to pass data as props:

<Tabs
  tabs={[
    { value: "react", label: "React", content: <ReactContent /> },
    { value: "vue", label: "Vue", content: <VueContent /> },
  ]}
/>

This works fine until the requirements change. Maybe you want to put something between the tab list and the first panel. Maybe some tabs need icons and some don't. Maybe one panel needs to stay mounted in the background instead of unmounting when inactive.

With the data-driven API, every variation requires a new prop. With compound components, the consumer just rearranges or extends the JSX. The component library doesn't need to anticipate every use case.

Controlled vs uncontrolled

The example above is uncontrolled — Tabs owns the state. For cases where the parent needs to control which tab is active (say, from a URL param), you can make it controlled:

interface TabsProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  children: React.ReactNode;
}

function Tabs({ value, defaultValue = "", onValueChange, children }: TabsProps) {
  const [internalValue, setInternalValue] = useState(defaultValue);
  const active = value ?? internalValue;

  function setActive(next: string) {
    if (value === undefined) setInternalValue(next);
    onValueChange?.(next);
  }

  return (
    <TabsContext.Provider value={{ active, setActive }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

When value is passed in, external state takes over. When it's not, the component manages its own. This is the same pattern React's own form inputs use for controlled vs uncontrolled behavior.

When to use it

The compound component pattern fits well when:

  • You have a group of related UI elements that share state
  • Consumers need flexibility in layout or composition
  • The "obvious" API would require too many props to handle different configurations

It fits less well for simple components that don't need internal coordination, or when the composition is always the same. If every consumer uses Tabs the same way, a data-driven API is simpler and harder to misuse.

The pattern also shows up in popular libraries: Radix UI, Headless UI, and React Aria all use it extensively. Reading their source is a good way to see how the pattern scales to production-quality, accessible components.

The shift in thinking

What changed for me was realizing that flexibility in a component API doesn't come from more props — it comes from giving consumers control over composition. The compound component pattern hands the layout back to the consumer while keeping the logic internal. That's a cleaner separation than trying to predict every variation a component might need.

← Older
The React Context API: Shared State Without a Library
Newer →
Testing React Components with Vitest and Testing Library

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.