DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on • Originally published at github.com

Compound components in React: how <Tabs.Tab> and <Tabs.Panel> share state without a single prop between them

You've used <Tabs> where you write <Tabs.Tab> and <Tabs.Panel> and they magically stay in sync — you never pass an active prop or an onChange between them. That's the compound component pattern, and once you've seen the ~20 lines behind it, you'll reach for it constantly. So I built an interactive page that shows the pattern, the implementation, and the mess it replaces.

▶ Live demo: https://compound-components-omega.vercel.app/
Source: https://github.com/dev48v/compound-components

The markup you get to write

<Tabs defaultValue="overview">
  <Tabs.List>
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="pricing">Pricing</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview"></Tabs.Panel>
  <Tabs.Panel value="pricing"></Tabs.Panel>
</Tabs>
Enter fullscreen mode Exit fullscreen mode

No state in the caller. No active prop. No onChange. Clicking a tab switches the panel. How?

The whole trick is Context

The parent owns the state and shares it; the children read it:

const TabsContext = createContext(null);

function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      {children}
    </TabsContext.Provider>
  );
}

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

Tabs.List  = ({ children }) => <div role="tablist">{children}</div>;

Tabs.Tab = ({ value, children }) => {
  const { active, setActive } = useTabs();
  return (
    <button aria-selected={active === value} onClick={() => setActive(value)}>
      {children}
    </button>
  );
};

Tabs.Panel = ({ value, children }) => {
  const { active } = useTabs();
  return active === value ? <div>{children}</div> : null;
};
Enter fullscreen mode Exit fullscreen mode

Three things make it work:

  1. The parent owns the state. active lives in <Tabs> and goes into a context provider.
  2. Children consume it implicitly. Every Tab and Panel calls useTabs() — they read and update the shared value without the caller wiring anything.
  3. Sub-components hang off the parent (Tabs.Tab = …). That namespacing is the "compound" part: it signals the pieces belong together, and the useTabs() guard throws a clear error if someone uses a Tab outside a Tabs.

A nice consequence: any descendant can read the context, at any depth. In the demo there's an extra little component buried in the tree that just prints active = "pricing" — no props reached it; it asked the context directly.

What it replaces

Without the pattern, the caller holds the state and threads it onto every element:

const [active, setActive] = useState("overview");

<Tab value="overview" active={active} onChange={setActive}></Tab>
<Tab value="pricing"  active={active} onChange={setActive}></Tab>
<Panel value="overview" active={active}></Panel>
<Panel value="pricing"  active={active}></Panel>
Enter fullscreen mode Exit fullscreen mode

Every element repeats active / onChange. Add a tab and you wire it again. The compound version moves all of that inside <Tabs>, so the caller only declares structure. This is exactly how Radix UI, Reach UI, and Headless UI build their primitives.

It's a reusable technique, not a one-off

The demo also builds an Accordion with the identical approach — a parent with createContext + useState, items that useContext — just a different shape of state (which item is open). Once you see Tabs and Accordion share the same skeleton, you start seeing it everywhere: menus, selects, radio groups, steppers, dialogs.

When to reach for it

When a set of components has to coordinate one piece of state and you want the usage to read as plain markup. If the components are genuinely independent, plain props are still simpler — don't add a context to pass one value one level down.

Flip the tabs, expand the accordion, and read the implementation side by side in the demo. If it made the pattern click, a star helps others find it: https://github.com/dev48v/compound-components

Top comments (0)