Lesson 17 / 25
Compound Components
A component family sharing a single piece of state; the effect of implicit sharing on the prop surface, the component building the relational attributes itself, and checking the implicit contract at runtime.
Contents
As the filter panel grew, it split into two sections: threshold settings and date range. The two are not visible at the same time; the user switches from one to the other by pressing a tab header. The structure has three parts — the list of headers, the headers themselves, and the bodies — and all three have to know which one is active.
Giving this information to every part as a prop brings back the prop drilling measured in the first lesson. Letting each part keep its own copy allows two bodies to be visible at the same time. What is needed is a family written as separate components that share a single piece of state.
The Family and Implicit Sharing
A compound component is a set of components built from members that are not meaningful on their own. The parent member holds and provides the state; the child members read that state directly. The caller does not pass the state from hand to hand between the members.
Sharing is done with the mechanism from the Context Passing lesson: the parent member provides a value at that point in the tree, and the child members read it during their own render. There is a single condition for this to work — the child members must render after the parent member’s render. The lazy construction of the component tree satisfies this condition on its own.
Naming the members as a property of the parent component (like Tabs.Header) is not a
technical requirement; it is a naming decision that makes their membership visible in the
source text.
// compound.mjs — a component family that works together, and its implicit contract const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter(Boolean) }); const text = (value) => ({ name: "#text", attrs: {}, children: [], text: value }); // Context stack: children can read it because they run during the parent's render. const stack = []; function provideContext(value, renderChildren) { stack.push(value); try { return renderChildren(); } finally { stack.pop(); } } function readContext(caller) { const v = stack[stack.length - 1]; if (!v) throw new Error(`${caller} can only be used inside the Tabs component`); return v; } // Prop counter: count the props the caller writes. let propsWritten = 0; const count = (props) => { propsWritten += Object.keys(props).length; return props; }; // --- Compound component family --- const Tabs = (o) => { count(o); const shared = { active: o.active, select: o.select, rootId: o.id }; return element("div", { class: "tabs" }, provideContext(shared, o.children)); }; Tabs.List = (o) => { count(o); readContext("Tabs.List"); return element("div", { role: "tablist" }, o.children()); }; Tabs.Header = (o) => { count(o); const context = readContext("Tabs.Header"); const isActive = context.active === o.value; return element("button", { id: `${context.rootId}-header-${o.value}`, role: "tab", type: "button", "aria-selected": String(isActive), "aria-controls": `${context.rootId}-body-${o.value}`, tabindex: isActive ? 0 : -1, }, text(o.label)); }; Tabs.Body = (o) => { count(o); const context = readContext("Tabs.Body"); if (context.active !== o.value) return null; return element("div", { id: `${context.rootId}-body-${o.value}`, role: "tabpanel", "aria-labelledby": `${context.rootId}-header-${o.value}`, tabindex: 0, }, o.children()); }; // --- Usage --- const printTree = (node, depth = 0) => { if (!node) return []; if (node.text !== undefined) return [`${" ".repeat(depth)}"${node.text}"`]; const attrsText = Object.entries(node.attrs).map(([k, v]) => ` ${k}="${v}"`).join(""); return [`${" ".repeat(depth)}<${node.name}${attrsText}>`, ...node.children.flatMap((c) => printTree(c, depth + 1))]; }; propsWritten = 0; const compoundTree = Tabs({ id: "filter", active: "threshold", select: () => {}, children: () => [ Tabs.List({ children: () => [ Tabs.Header({ value: "threshold", label: "Threshold" }), Tabs.Header({ value: "range", label: "Date range" }), ] }), Tabs.Body({ value: "threshold", children: () => [text("-40 to 40")] }), Tabs.Body({ value: "range", children: () => [text("last 24 hours")] }), ], }); const compoundProps = propsWritten; console.log(printTree(compoundTree).join("\n")); // The same tree, with the shared state handed to every child by hand. propsWritten = 0; const explicitHeader = (o) => { count(o); return element("button", { id: `${o.rootId}-header-${o.value}`, role: "tab", type: "button", "aria-selected": String(o.active === o.value), "aria-controls": `${o.rootId}-body-${o.value}`, tabindex: o.active === o.value ? 0 : -1 }, text(o.label)); }; const explicitBody = (o) => { count(o); return o.active !== o.value ? null : element("div", { id: `${o.rootId}-body-${o.value}`, role: "tabpanel", "aria-labelledby": `${o.rootId}-header-${o.value}`, tabindex: 0 }, o.children()); }; const explicitTree = element("div", { class: "tabs" }, element("div", { role: "tablist" }, explicitHeader({ rootId: "filter", active: "threshold", select: () => {}, value: "threshold", label: "Threshold" }), explicitHeader({ rootId: "filter", active: "threshold", select: () => {}, value: "range", label: "Date range" })), explicitBody({ rootId: "filter", active: "threshold", value: "threshold", children: () => [text("-40 to 40")] }), explicitBody({ rootId: "filter", active: "threshold", value: "range", children: () => [text("last 24 hours")] })); console.log("\ntrees identical:", JSON.stringify(compoundTree) === JSON.stringify(explicitTree)); console.log(`props written by the caller — compound: ${compoundProps}, explicit forwarding: ${propsWritten}`); // --- Checking the implicit contract --- console.log("\nuse outside the family:"); try { Tabs.Header({ value: "threshold", label: "Threshold" }); } catch (e) { console.log(" ", e.message); } console.log("\na body attached to the wrong value:"); stack.length = 0; const broken = Tabs({ id: "filter", active: "threshold", select: () => {}, children: () => [ Tabs.List({ children: () => [Tabs.Header({ value: "threshold", label: "Threshold" })] }), Tabs.Body({ value: "thresholdd", children: () => [text("-40 to 40")] }), ], }); const collect = (d, f, c = []) => { if (!d) return c; if (f(d)) c.push(d); d.children?.forEach((x) => collect(x, f, c)); return c; }; const headers = collect(broken, (d) => d.attrs.role === "tab"); const bodies = collect(broken, (d) => d.attrs.role === "tabpanel"); console.log(" header count:", headers.length, "| body count:", bodies.length); for (const h of headers) { const target = bodies.find((b) => b.attrs.id === h.attrs["aria-controls"]); console.log(` ${h.attrs.id} → aria-controls="${h.attrs["aria-controls"]}" ${target ? "target found" : "NO TARGET"}`); }
<div class="tabs">
<div role="tablist">
<button id="filter-header-threshold" role="tab" type="button" aria-selected="true" aria-controls="filter-body-threshold" tabindex="0">
"Threshold"
<button id="filter-header-range" role="tab" type="button" aria-selected="false" aria-controls="filter-body-range" tabindex="-1">
"Date range"
<div id="filter-body-threshold" role="tabpanel" aria-labelledby="filter-header-threshold" tabindex="0">
"-40 to 40"
trees identical: true
props written by the caller — compound: 13, explicit forwarding: 18
use outside the family:
Tabs.Header can only be used inside the Tabs component
a body attached to the wrong value:
header count: 1 | body count: 0
filter-header-threshold → aria-controls="filter-body-threshold" NO TARGET
The Relationships the Family Produces
The first section of the output has five attributes the caller never wrote: two ids,
aria-selected, aria-controls, and aria-labelledby. These are the attributes that
build the relationship between the members; they announce which body a header controls
and which header names a body.
The family building the relationship matters for two reasons. First, the uniqueness of the ids is derived from a single root; a second tab group on the same page takes a different root name and the ids do not collide. Second, the relationship is not left to the caller. The problem seen in the Label-Field Relationship lesson in the Web Fundamentals and HTML course applies here too: a hand-written relationship gets forgotten somewhere, and when it is forgotten, nothing changes visually — only the assistive-technology user is affected.
Focus management also belongs to the family. In the output, the inactive header’s
tabindex value is -1; this is the roving tabindex arrangement from The Browser and the
Web Platform course. The tab list holds a single stop in keyboard order, and navigation
inside the list happens with the arrow keys.
The Cost of Implicit Sharing
The two trees are identical. The difference is only in the number of props the caller writes: 13 in the compound family, 18 with explicit forwarding. The gap of five comes from handing the shared state to every member by hand, and it grows with the number of members. In a four-tab group, the gap becomes ten.
The price of the gain is a drop in discoverability. Looking at Tabs.Header‘s
signature does not show what state it reads; the signature only has value and label.
The component’s real input is a context that does not appear in its signature. This
resembles the silent decisions that accumulate in a wrapper component’s body in the first
lesson; here the decision lives not in the body but in the tree position.
The second cost is that a member cannot be carried on its own. Tabs.Header does not work
when pulled out of the family. This is not a flaw but a definition — but that definition
needs to be visible at runtime.
Checking the Implicit Contract
The third section of the output shows what happens when a member is called outside the family: an exception that names itself and states where it belongs. Testing that the context exists before reading it is a one-line check, and it is mandatory in compound components. Without the check, the result would be a meaningless error trying to read a field off an undefined value.
The fourth section shows a sneakier flaw. The body’s value is written as thresholdd; the
header is bound to the value threshold. No exception is thrown, because both members are
inside the family and can read the context. The result is a tree with one tab header and
no body at all, and the header’s aria-controls attribute points at an id that does not
exist. This is the same class of failure as the mismatched slot name in the Templates and
Slots lesson: a typo disappears silently.
Checking this class of flaw can be done in two places. At runtime, the parent member can collect the values of the members beneath it and report the ones that do not match; the cost is an extra pass over the tree. At the type level, member values are drawn from a fixed union type and a mismatched value does not compile; the cost is that values cannot be generated at runtime. The second path is cheaper and catches what it can catch earlier.
When a Family Is Not Built
A compound component is correct when there is real shared state among the members. In the tab group, such state exists: which one is active. It does not exist between the header and body sections of the measurement table; the two only stand side by side. Building a family in a place like that pays the cost of implicit sharing without getting anything for it; slot-based composition is enough.
The second limit is the number of members. In a family of seven members, what the caller has to learn is not a component but a small language; the valid nesting rules among the members become that language’s syntax. A surface like that only pays off when the reuse count is high.
The third limit is the family depending on order. If the parent member assumes the child members arrive in a particular order, a wrapper the caller inserts in between breaks the arrangement. Sharing set up through context does not carry this dependency — context is read down the depth of the tree, regardless of order — and this is its advantage over designs that scan the child list directly.
Summary
- A compound component is a family built from members that are not meaningful on their own and that share a single piece of state through context.
- The condition for sharing is that child members render after the parent member; the lazy construction of the component tree guarantees this.
- The family produces the id and relationship attributes between members itself; this removes the silent flaws seen in hand-written relationships.
- Implicit sharing lowers the number of props the caller writes, at the cost of making a member’s input unreadable from its signature.
- Use outside the family must be reported at runtime with an exception; the matching of values between members is checked either at the type level or by scanning the tree.
- Without shared state, no family is built; slot-based composition gives the same structure at no cost.
Next Step
The tab family still mixes two things together: holding which tab is active and deciding how a tab looks live in the same component. This causes no problem in a small family, but it does in the measurement table — the table fetches the data, sorts it, paginates it, and renders the cells, all at once. A component like that can neither be tested without the data layer changing nor reused with a different data source. The next lesson separates these two responsibilities and measures the effect of that separation on testability by the number of test doubles.
To keep your progress and take notes, Log in
My notes
Log in to take notes.