Compound Components

[!IMPORTANT] The Compound Components pattern allows you to build components that work together to form a complete UI unit, sharing state implicitly behind the scenes. Think of <select> and <option> in HTML—they work together without you manually passing state between them.

The Problem: Prop Explosion

When building reusable components, you often start by passing everything as props. But as the component grows, you end up with a “God Component” that takes dozens of props just to handle different configurations.

// ❌ Hard to manage, hard to extend
<Tabs
  items={[
    { label: 'Tab 1', content: 'Content 1' },
    { label: 'Tab 2', content: 'Content 2' }
  ]}
  activeColor="blue"
  onChange={index => console.log(index)}
  defaultIndex={0}
/>

What if you want to add an icon to one tab? Or disable just one tab? Or put the tabs at the bottom? You’d need more props: renderTabLabel, tabPosition, disabledIndices, etc.

The Solution: Compound Components

Instead of one giant component, we create a set of components that work together. They share state (like “which tab is active”) implicitly using React Context.

// ✅ Flexible, declarative, easy to read
<Tabs defaultIndex={0} onChange={index => console.log(index)}>
  <Tabs.List>
    <Tabs.Tab index={0}>Tab 1</Tabs.Tab>
    <Tabs.Tab index={1} disabled>Tab 2</Tabs.Tab>
    <Tabs.Tab index={2}>Tab 3</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel index={0}>Content 1</Tabs.Panel>
    <Tabs.Panel index={1}>Content 2</Tabs.Panel>
    <Tabs.Panel index={2}>Content 3</Tabs.Panel>
  </Tabs.Panels>
</Tabs>

This is the Compound Components pattern.

Interactive Demo: Tabs Builder

Explore how the Tabs component coordinates with its children (Tab and Panel) to manage the active state without you seeing it.

How It Works: The Context API

The magic behind compound components is React’s Context API. The parent component (Tabs) creates a context provider, and the child components (Tab, Panel) consume that context.

1. Create the Context

First, we define the shape of our context and create it.

import React, { createContext, useContext, useState, ReactNode } from 'react';

type TabsContextType = {
  activeTab: number;
  setActiveTab: (index: number) => void;
};

const TabsContext = createContext<TabsContextType | undefined>(undefined);

// Custom hook for consuming context safely
function useTabs() {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs components must be used within a <Tabs /> provider');
  }
  return context;
}

2. The Parent Component (Provider)

The parent component manages the state and provides it to its children.

interface TabsProps {
  children: ReactNode;
  defaultIndex?: number;
  onChange?: (index: number) => void;
}

function Tabs({ children, defaultIndex = 0, onChange }: TabsProps) {
  const [activeTab, setActiveTabState] = useState(defaultIndex);

  const setActiveTab = (index: number) => {
    setActiveTabState(index);
    onChange?.(index);
  };

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs-container">{children}</div>
    </TabsContext.Provider>
  );
}

3. The Child Components (Consumers)

The children use the useTabs hook to read and update the state.

interface TabProps {
  index: number;
  children: ReactNode;
  disabled?: boolean;
}

function Tab({ index, children, disabled }: TabProps) {
  const { activeTab, setActiveTab } = useTabs();
  const isActive = activeTab === index;

  return (
    <button
      className={`tab-btn ${isActive ? 'active' : ''}`}
      onClick={() => !disabled && setActiveTab(index)}
      disabled={disabled}
    >
      {children}
    </button>
  );
}

interface PanelProps {
  index: number;
  children: ReactNode;
}

function Panel({ index, children }: PanelProps) {
  const { activeTab } = useTabs();
  return activeTab === index ? <div className="tab-panel">{children}</div> : null;
}

4. Exposing as Compound Components

Finally, attach the sub-components to the main Tabs component for a clean API.

// Assign sub-components
Tabs.Tab = Tab;
Tabs.Panel = Panel;

export default Tabs;

Visualizing the Component Tree

Here is how the data flows in our Compound Component structure:

<Tabs /> (Provider) State: activeIndex = 1 <Tab /> (Consumer) <Panel /> (Consumer) Context Updates

Flexible Compound Components

You can make your compound components even more flexible by not enforcing direct parent-child relationships. Using React.Children.map allows you to clone children and inject props, but using Context allows components to be deeply nested.

Example: Separating List and Panels

With Context, we can group tabs and panels separately:

<Tabs>
  <div className="sidebar">
    <Tabs.Tab index={0}>Profile</Tabs.Tab>
    <Tabs.Tab index={1}>Settings</Tabs.Tab>
  </div>
  <div className="main-content">
    <Tabs.Panel index={0}>Profile Page</Tabs.Panel>
    <Tabs.Panel index={1}>Settings Page</Tabs.Panel>
  </div>
</Tabs>

This works perfectly because Tab and Panel consume the context regardless of where they are in the DOM tree, as long as they are descendants of <Tabs>.

[!TIP] Use this pattern when you want to give the user of your component full control over the layout (HTML structure) while you control the logic and state.

Summary

  • Compound Components share state implicitly between a parent and its children.
  • Context API is the primary tool for implementing this pattern in modern React.
  • It solves the “Prop Explosion” problem by avoiding passing configuration props deep down.
  • It enables declarative and composable APIs.