Chapter 2 of 4

Compound Components

Components that work together and share state implicitly.

A compound component is a family of components that only make sense together: Tabs with TabList, Tab and TabPanel. The parent holds the state and shares it with its children through context.

const TabsContext = createContext(null);

function Tabs({ defaultTab, children }) {
  const [active, setActive] = useState(defaultTab);
  const value = useMemo(() => ({ active, setActive }), [active]);

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

function Tab({ id, children }) {
  const { active, setActive } = useContext(TabsContext);
  return (
    <button
      role="tab"
      aria-selected={active === id}
      onClick={() => setActive(id)}
    >
      {children}
    </button>
  );
}

function TabPanel({ id, children }) {
  const { active } = useContext(TabsContext);
  return active === id ? <div role="tabpanel">{children}</div> : null;
}

Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
The caller controls the markup; the parent controls the behaviour.
<Tabs defaultTab="overview">
  <Tabs.Tab id="overview">Overview</Tabs.Tab>
  <Tabs.Tab id="settings">Settings</Tabs.Tab>

  <Tabs.Panel id="overview"><Overview /></Tabs.Panel>
  <Tabs.Panel id="settings"><Settings /></Tabs.Panel>
</Tabs>

The user of the component decides layout, wrappers and ordering, while the shared state stays hidden. This is how most headless UI libraries are built.

The older cloneElement approach

Before context was convenient, compound components injected props by cloning their children. You still meet this in older code.

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

  return Children.map(children, (child, index) =>
    cloneElement(child, { isActive: index === active, onSelect: () => setActive(index) })
  );
}