<select>/<option> in React: the compound pattern
In HTML, <select> and <option> cooperate
with zero props between them — the select owns the state, each option reflects it.
React gives you no built-in pairing, so you build one: a parent component holds the
state and publishes it through a Context; related
sub-components (attached as static properties, Tabs.Tab,
Tabs.TabPanel) read it with useContext. The consumer writes
<Tabs><Tabs.Tab/></Tabs> and never passes a state prop —
exactly the clean ergonomics of <select>.
| piece | role | analogy |
|---|---|---|
Context |
the shared channel, created once via createContext |
the invisible air between <select> and <option> that carries state |
Parent (Tabs) |
holds state + wraps children in the Provider |
the <select> element — it alone owns open/selected |
Static assignment (Tabs.Tab =) |
attaches sub-components as properties of the parent | how the DOM "knows" <option> belongs to <select> |
Child consumer (useContext) |
reads the shared state — no props passed in | the <option> reflecting "selected" without being told |
1 · the compound Tabs you write (edit me)
2 · Babel compiles JSX → element tree
<Tabs.Tab index={0}> becomes
React.createElement(Tabs.Tab, {"{index:0}"}) — the member expression
resolves to the statically-attached function. Each click calls ctx.setActive,
the Provider republishes, and every consumer re-renders with the new value.
// (hit "compile & render" to see Babel's output)
3 · live React (implicit state, proven)
Click a tab heading — no props are passed between Tabs and Tabs.Tab;
the active index flows through Context. The gold-check drives
this automatically: assert tab-0 active, click tab-1 → tab-2 → tab-0, asserting the right
panel mounts and the active flag moves each time.
active tab: — · gold: tab-0 → click 1 → click 2 → click 0, panel + data-active match each step
intent → pattern
| intent | pattern | why |
|---|---|---|
| share state implicitly | var Ctx = createContext({}) + Provider in parent |
avoids prop-drilling through N intermediate levels |
| attach a sub-component | Tabs.Tab = function (props) {"{...}"} |
static property → consumer writes Tabs.Tab, clean namespaced API |
| read the shared state | var ctx = useContext(Ctx) inside a child |
no props passed; child stays decoupled from parent internals |
| conditionally mount a panel | if (ctx.active !== index) return null |
only the active panel is in the tree — lazy-friendly |
| keep the context value stable | useMemo(() ={"("} {"{active, setActive}"} {")"}, [active]) |
prevents every consumer re-rendering on unrelated parent renders |