Sharing state with component context
Compound components share state through a context private to the component module — sub-components read the active value with no prop drilling and the consumer never sees it; leaking it app-wide or an unstable value object is the failure mode.
A compound component lets a consumer write <Tabs><Tab/><Tab/><Panel/></Tabs> — flat, readable markup where the children look independent. But they aren’t: the active tab has to reach every Tab and every Panel so they know to highlight or show themselves. The obvious way is to pass activeId and setActiveId down as props to each child. That works for one level and collapses the moment the consumer nests a Tab inside a <div> or their own wrapper — the props can’t reach it.
The mechanism that makes compound components actually ergonomic is a context created inside the component’s own module — private to it, invisible to the consumer — that every sub-component reads to find the active value. This lesson is about that context: how to scope it, why it stays internal, and the two ways it goes wrong.
After this lesson you can build a compound component (Tabs) whose sub-components share state through a module-private context instead of props; explain why that context is created inside the module and never exported app-wide; and name the pattern’s two failure modes — leaking the context so unrelated code can read or provide it, and an unstable provider value that re-renders every panel on each tab change because context value identity changed.
Prop drilling breaks compound components: the children can be nested arbitrarily, so props can’t reach them. The consumer composes the markup, which means you don’t control where Tab and Panel sit in the tree. Any approach that threads activeId through props assumes a fixed shape you don’t have.
// what the consumer writes — note the wrapper div you didn't put there
<Tabs defaultId="a">
<div className="tab-row">
<Tab id="a">Account</Tab>
<Tab id="b">Billing</Tab>
</div>
<Panel id="a">…</Panel>
<Panel id="b">…</Panel>
</Tabs>Tabs cannot pass a prop to <Tab id="a"> — there’s a <div> in between that Tabs doesn’t render. Props go one level; the active state has to reach an arbitrary depth.
Create a context inside the component’s module — it is the private channel the sub-components share. The createContext call lives next to the component, not in some app-wide providers file. Nothing outside this module imports it. That privacy is the whole point: the consumer composes Tabs and never knows a context exists.
"use client"; // context + state, so this subtree is a Client Component
import { createContext, useContext, useId, useState, useMemo } from "react";
interface TabsCtx { activeId: string; setActiveId: (id: string) => void; }
// not exported — private to this module
const TabsContext = createContext<TabsCtx | null>(null);
// one internal hook the sub-components call; it also enforces "must be inside <Tabs>"
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Tab/Panel must be used inside <Tabs>");
return ctx;
}TabsContext and useTabs are file-local. The error in useTabs turns a misuse (<Tab> rendered outside <Tabs>) into a clear message instead of a confusing null.
The parent owns the state and provides it; sub-components read the active value with no props passed to them. Tabs holds activeId in useState, wraps its children in the provider, and each Tab/Panel calls useTabs() to get the current value — regardless of how deep it is in the consumer’s markup.
export function Tabs({ children, defaultId }: { children: React.ReactNode; defaultId: string }) {
const [activeId, setActiveId] = useState(defaultId);
const value = useMemo(() => ({ activeId, setActiveId }), [activeId]);
return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}
export function Tab({ id, children }: { id: string; children: React.ReactNode }) {
const { activeId, setActiveId } = useTabs();
return (
<button role="tab" aria-selected={activeId === id} onClick={() => setActiveId(id)}>
{children}
</button>
);
}
export function Panel({ id, children }: { id: string; children: React.ReactNode }) {
const { activeId } = useTabs();
return activeId === id ? <div role="tabpanel">{children}</div> : null;
}No activeId prop appears in the consumer’s JSX. The state flows through the context, so the markup stays flat and the children stay relocatable.
Failure mode one: leaking the context app-wide. Failure mode two: an unstable value that re-renders every consumer on each change. These are the two ways this pattern degrades, and both are about what the context is, not what it does.
Leaking: if you export TabsContext and provide it from a top-level app provider so “anything can read the active tab”, you’ve turned a component-scoped detail into global state. Now two Tabs on one page share one active id, unrelated code couples to your internals, and the encapsulation that made the pattern ergonomic is gone.
Identity: context consumers re-render whenever the provider’s value is a new reference — not when the data inside changes. A fresh object literal each render is always a new reference.
// BUG: new object every render → every Tab and Panel re-renders on ANY parent render,
// even renders that didn't change activeId
return <TabsContext.Provider value={{ activeId, setActiveId }}>{children}</TabsContext.Provider>;
// FIX: memoize so the reference is stable until activeId actually changes
const value = useMemo(() => ({ activeId, setActiveId }), [activeId]);setActiveId from useState is already stable, so the only real dependency is activeId. With the useMemo, switching tabs re-renders only what depends on the change, not the whole subtree on every unrelated render.
Drilling props vs. a private context — the same Tabs, made relocatable. Start with the prop-drilled version that only works when the children are direct, ordered siblings:
// BEFORE: Tabs clones children to inject activeId — brittle and leaky
function Tabs({ children, defaultId }: { children: React.ReactElement[]; defaultId: string }) {
const [activeId, setActiveId] = useState(defaultId);
return (
<>
{React.Children.map(children, (child) =>
// assumes every child accepts activeId/onSelect, and is a *direct* child
React.cloneElement(child, { activeId, onSelect: setActiveId }),
)}
</>
);
}
// breaks the instant a consumer wraps a Tab in a <div> — cloneElement only reaches direct childrencloneElement forces every child to accept injected props and reaches exactly one level — wrap a Tab in a layout div and it stops receiving activeId. The context version removes that constraint:
// AFTER: state shared through a module-private context; children can nest anywhere
const TabsContext = createContext<TabsCtx | null>(null); // not exported
function Tabs({ children, defaultId }: { children: React.ReactNode; defaultId: string }) {
const [activeId, setActiveId] = useState(defaultId);
const value = useMemo(() => ({ activeId, setActiveId }), [activeId]);
return <TabsContext.Provider value={value}>{children}</TabsContext.Provider>;
}
// Tab and Panel call useTabs() — depth-independent, no injected props, identity-stable valueThe “after” is strictly more capable and simpler at the call site: children are plain ReactNode, not a constrained ReactElement[], and the consumer can nest them freely. The cost paid — a context and a useMemo — is exactly the cost that buys depth-independence and scoped re-renders. That trade is worth it precisely because compound components exist to let the consumer control the markup.
▸Why this works
Why keep the context private instead of exporting it as the component’s “API”? Because the context is an implementation detail of how the sub-components talk to each other, not a contract with the consumer. The public API is <Tabs>, <Tab>, <Panel>. If you export TabsContext, consumers will read it directly, and now you can’t change the value shape (rename activeId, add registerTab, switch to a reducer) without a breaking change. Keeping it module-private means the entire coordination mechanism is free to evolve as long as the JSX API stays stable — the same encapsulation reason you’d keep a class field private.
▸Common mistake
The subtle re-render bug isn’t “I forgot useMemo” in the abstract — it’s that context compares the value by reference, not by contents. Developers reason “activeId didn’t change, so nothing re-renders” and are wrong: a new { activeId, setActiveId } object literal is a new reference every render, so every consumer re-renders on every parent render, including ones triggered by unrelated state elsewhere in Tabs. In a small Tabs you won’t notice. In a <Tabs> whose panels each hold an expensive subtree, an unstable value turns one unrelated re-render into a full re-render of every panel. Memoize the value (or split static and dynamic into two contexts) so identity changes only when the data does.
Compound components share state through a context created inside the component’s own module — private, unexported, invisible to the consumer. The parent (Tabs) owns the state, wraps children in the provider, and each sub-component (Tab, Panel) reads the active value with an internal useTabs() hook, so the active state reaches children at any nesting depth without prop drilling or cloneElement. That component-scoped context is the mechanism that makes the pattern ergonomic, and keeping it internal is what lets the coordination evolve behind a stable JSX API. Two failure modes define the edges: leaking the context app-wide turns a private detail into global state and breaks encapsulation; an unstable value re-renders every consumer on every parent render because context compares value by reference, not contents — so memoize the value (or split it) and switching a tab re-renders only what the change touches.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.