Lesson 15 / 25
Composition Patterns
Comparing the wrapper component with slot-based composition; the option explosion, measuring prop drilling, composition over inheritance, and the condition under which each pattern is correct.
Contents
The previous topic carried a single component all the way through: local state, cleaning up its side effects, memoizing its derived values, isolating errors, and rendering outside the tree. A component is now a complete unit on its own. But an interface is not made of a single component. The North Slope Measurement Station page has fifteen measurement badges, a measurement table, and a filter panel; all three are built from the same building blocks.
This lesson addresses the question that remains once a single component is solved: when two components come together, what does one know about the other? The answer has two distinct patterns, and the choice between them determines how much the component will change over its lifetime.
Two Patterns
Composition is the act of building one component out of others. (The term was also used in the Layout Systems and Responsive Design course, M14/K03, for merging paint layers; here it means building the component tree.) It has two patterns.
Wrapping. A component calls another component internally and exposes its own set of props to the outside. (The prop defined in the Component Concept lesson is referred to as a prop, for short, throughout this topic.) The caller never sees the wrapped component; it only fills in the wrapper’s props. The structure is fixed inside the component.
Slot-based composition. The component leaves a hole in its internal structure and takes the tree that goes into that hole from the outside. The caller decides what goes in; the component only knows where it goes. The Templates and Slots lesson in the Web Components and Offline topic showed this with the browser’s own slot-assignment algorithm; at the framework level, the same idea appears as a prop whose value is not text or a number but a piece of tree.
The two can produce the same output. Where they diverge is not the output but the behavior under change.
Option Explosion
Start with the measurement badge. The first version renders a title, a value, and a unit. Then it is asked to show whether the threshold was exceeded; then the name of the source; then a trend arrow; then a refresh button; then a note line.
In the wrapper pattern, every new request adds a prop and a branch.
// composition.mjs — comparing the trees produced by a wrapper component and slot-based composition const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter(Boolean) }); const text = (value) => ({ name: "#text", attrs: {}, children: [], text: value }); // Shared core: the badge that renders the title, value, and unit. const core = ({ title, value, unit }, ...extra) => element("div", { class: "badge" }, element("span", { class: "badge-title" }, text(title)), element("output", { class: "badge-value" }, text(`${value} ${unit}`)), ...extra); // (a) Wrapper component: every new requirement adds a prop and a branch. function wrapperBadgeV1({ title, value, unit, thresholdExceeded, showSource, source }) { const extra = []; if (thresholdExceeded) extra.push(element("span", { class: "badge-warning" }, text("threshold exceeded"))); if (showSource) extra.push(element("small", { class: "badge-source" }, text(source))); return core({ title, value, unit }, ...extra); } // When the sixth requirement arrives, the same body is reopened. function wrapperBadgeV2({ title, value, unit, thresholdExceeded, showSource, source, trend, refreshButton, noteText }) { const extra = []; if (thresholdExceeded) extra.push(element("span", { class: "badge-warning" }, text("threshold exceeded"))); if (showSource) extra.push(element("small", { class: "badge-source" }, text(source))); if (trend) extra.push(element("span", { class: "badge-trend" }, text(trend))); if (refreshButton) extra.push(element("button", { type: "button" }, text("refresh"))); if (noteText) extra.push(element("p", { class: "badge-note" }, text(noteText))); return core({ title, value, unit }, ...extra); } // (b) Slot-based composition: the component leaves a hole, the caller fills it. const slotBadge = ({ title, value, unit, extra = [] }) => core({ title, value, unit }, ...extra); // Compare the trees the two approaches produce for the same input. const printTree = (node, depth = 0) => { const line = node.text !== undefined ? `${" ".repeat(depth)}"${node.text}"` : `${" ".repeat(depth)}${node.name}${node.attrs.class ? "." + node.attrs.class : ""}`; return [line, ...node.children.flatMap((c) => printTree(c, depth + 1))]; }; const wrapped = wrapperBadgeV2({ title: "Temperature", value: -4.2, unit: "°C", thresholdExceeded: true, showSource: true, source: "station-3", trend: null, refreshButton: false, noteText: null, }); const slotted = slotBadge({ title: "Temperature", value: -4.2, unit: "°C", extra: [ element("span", { class: "badge-warning" }, text("threshold exceeded")), element("small", { class: "badge-source" }, text("station-3")), ], }); console.log("wrapped tree:"); console.log(printTree(wrapped).join("\n")); console.log("\nslotted tree:"); console.log(printTree(slotted).join("\n")); console.log("\ntrees identical:", JSON.stringify(wrapped) === JSON.stringify(slotted)); // Measurement: how does the surface of the two approaches grow as requirements increase? const bodyLines = (f) => f.toString().split("\n").length; const branchCount = (f) => (f.toString().match(/\bif \(/g) ?? []).length; console.log("\napproach body lines branches caller's options"); for (const [label, fn, needs] of [ ["wrapper (2 needs)", wrapperBadgeV1, 2], ["wrapper (5 needs)", wrapperBadgeV2, 5], ["slot-based", slotBadge, 0], ]) { console.log( `${label.padEnd(24)} ${String(bodyLines(fn)).padStart(11)} ${String(branchCount(fn)).padStart(9)} ` + `${(needs ? 2 ** needs : "unbounded").toString().padStart(17)}` ); }
wrapped tree:
div.badge
span.badge-title
"Temperature"
output.badge-value
"-4.2 °C"
span.badge-warning
"threshold exceeded"
small.badge-source
"station-3"
slotted tree:
div.badge
span.badge-title
"Temperature"
output.badge-value
"-4.2 °C"
span.badge-warning
"threshold exceeded"
small.badge-source
"station-3"
trees identical: true
approach body lines branches caller's options
wrapper (2 needs) 6 2 4
wrapper (5 needs) 10 5 32
slot-based 1 0 unbounded
The two trees are identical. Given an arbitrary output, which pattern was used cannot be told from it; the difference lies only in the source text and in the cost of change.
The table gives that cost. A wrapper component with five boolean props defines 32 distinct prop combinations. Not all of these combinations are meaningful — a refresh button together with a note line might never be wanted — but the component accepts all of them and has tested none. Five branches are five silent decisions about how five props interact with each other: the order in which they are appended is fixed, and the caller cannot change it.
In the slot-based version, the body is one line and there is no branching. The caller’s options cannot be counted, because the caller can supply any tree it wants. The component makes no assumption about that tree; it only appends it to the end of the core.
The real difference shows up on the sixth request. In the wrapper pattern, the sixth request reopens the component’s body: a new prop, a new branch, a file to review again. In the slot-based pattern, the component is left untouched; the new node is built on the caller’s side.
Prop Drilling
The option explosion accumulates inside the component’s own body. The second cost spreads across the tree: if the component that uses a value is far from the component that produces it, every layer in between has to carry that value. This is called prop drilling.
In the measurement table, the page level knows the unit-system preference, while the cell does the formatting. In between sit the table, the body, and the row.
// prop-drilling.mjs — counting the props that intermediate layers only forward const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter(Boolean) }); const text = (value) => ({ name: "#text", attrs: {}, children: [], text: value }); const format = (c, system) => (system === "si" ? `${c.toFixed(1)} °C` : `${(c * 1.8 + 32).toFixed(1)} °F`); // Each component reports which props it received and how many of those it actually uses. const ledger = []; const record = (name, received, used) => ledger.push({ name, received, used, forwarded: received.filter((a) => !used.includes(a)) }); // (a) Chained flow: the unit system is rewritten into every signature from the top layer down. function chainValue({ celsius, unitSystem }) { record("Value", ["celsius", "unitSystem"], ["celsius", "unitSystem"]); return element("output", {}, text(format(celsius, unitSystem))); } function chainRow({ title, celsius, unitSystem }) { record("Row", ["title", "celsius", "unitSystem"], ["title"]); return element("tr", {}, element("th", {}, text(title)), element("td", {}, chainValue({ celsius, unitSystem }))); } function chainBody({ measurements, unitSystem }) { record("Body", ["measurements", "unitSystem"], ["measurements"]); return element("tbody", {}, ...measurements.map((m) => chainRow({ ...m, unitSystem }))); } function chainTable({ title, measurements, unitSystem }) { record("Table", ["title", "measurements", "unitSystem"], ["title"]); return element("table", {}, element("caption", {}, text(title)), chainBody({ measurements, unitSystem })); } // (b) Slot-based flow: the value node is built where the unit system is already known; // intermediate layers carry the ready node, not the data. function slotValue({ celsius, unitSystem }) { record("Value/slot", ["celsius", "unitSystem"], ["celsius", "unitSystem"]); return element("output", {}, text(format(celsius, unitSystem))); } function slotRow({ title, cell }) { record("Row/slot", ["title", "cell"], ["title", "cell"]); return element("tr", {}, element("th", {}, text(title)), element("td", {}, cell)); } function slotBody({ rows }) { record("Body/slot", ["rows"], ["rows"]); return element("tbody", {}, ...rows); } function slotTable({ title, body }) { record("Table/slot", ["title", "body"], ["title", "body"]); return element("table", {}, element("caption", {}, text(title)), body); } const measurements = [{ title: "Upper Slope", celsius: -4.2 }, { title: "Lower Slope", celsius: 1.6 }]; const system = "imperial"; const chained = chainTable({ title: "North Slope", measurements, unitSystem: system }); const slotted = slotTable({ title: "North Slope", body: slotBody({ rows: measurements.map((m) => slotRow({ title: m.title, cell: slotValue({ celsius: m.celsius, unitSystem: system }) })), }), }); const printTree = (node, depth = 0) => [ node.text !== undefined ? `${" ".repeat(depth)}"${node.text}"` : `${" ".repeat(depth)}${node.name}`, ...node.children.flatMap((c) => printTree(c, depth + 1)), ]; console.log("tree produced by the chained flow:"); console.log(printTree(chained).join("\n")); console.log("trees identical:", JSON.stringify(chained) === JSON.stringify(slotted)); const summary = new Map(); for (const r of ledger) summary.set(r.name, r); console.log("\ncomponent received used forwarded only"); for (const [name, r] of summary) console.log(`${name.padEnd(13)} ${String(r.received.length).padStart(8)} ${String(r.used.length).padStart(5)} ${r.forwarded.join(", ") || "-"}`); const total = (slot) => [...summary.values()].filter((r) => r.name.includes("/slot") === slot).reduce((t, r) => t + r.forwarded.length, 0); console.log(`\nforwarded-only prop count in the chained flow: ${total(false)}`); console.log(`forwarded-only prop count in the slot-based flow: ${total(true)}`);
tree produced by the chained flow:
table
caption
"North Slope"
tbody
tr
th
"Upper Slope"
td
output
"24.4 °F"
tr
th
"Lower Slope"
td
output
"34.9 °F"
trees identical: true
component received used forwarded only
Table 3 1 measurements, unitSystem
Body 2 1 unitSystem
Row 3 1 celsius, unitSystem
Value 2 2 -
Value/slot 2 2 -
Row/slot 2 2 -
Body/slot 1 1 -
Table/slot 2 2 -
forwarded-only prop count in the chained flow: 5
forwarded-only prop count in the slot-based flow: 0
None of the three intermediate layers in the chained flow use the unit system; all three only pass it down. The count is five, because the measurement data follows the same path. In the slot-based flow this count is zero: the value node is built where the unit system is already known, and the intermediate layers carry the ready node.
The real cost of the chain is not in the signatures but in the dependency. Because the row component carries the unit system in its signature, it becomes aware that the unit system exists. When that prop is removed or renamed, the three components that never used it also have to change. In the slot-based flow, the row component receives a cell; it does not know what the cell is formatted against, and it is unaffected when that unknown thing changes.
This also explains why the mechanism in the Context Passing lesson exists. Context is the second way to break the chain: the value is provided at one point in the tree, and the node that needs it reads it directly. Slot-based composition and context are not interchangeable — one carries structure down, the other carries data down. In the measurement table, how the cell looks is a structural decision and belongs to the slot; the unit-system preference is data read everywhere on the page and belongs to context.
Composition Over Inheritance
An extreme application of the wrapper pattern is deriving components through inheritance: a base badge class, a warning badge that derives from it, a trending warning badge that derives from that. This path stalls in interface trees for two reasons.
First, inheritance is single-axis. If the badge that shows a warning and the badge that shows a source are separate branches, a third badge that wants both has no common ancestor; every new combination demands a new class. Five independent requests grow to 32 classes. In slot-based composition, the same 32 combinations are just 32 different calls to a single component.
Second, inheritance grants access into the body; composition does not. A derived class leans on its ancestor’s internal structure, and it breaks when the ancestor changes. In composition, the only thing a component promises to the outside is its prop surface; its internal structure is not part of the contract.
When Wrapping Is Correct
The advantage of slot-based composition is not unconditional. Wrapping is correct where the component needs to own a decision.
The threshold field in the filter panel is an example. The relationship between the field’s label, its error message, and its invalidity notice has to be fixed: the label must be bound to the field, the error message must be attached to the field as additional description, and the invalid state must be reflected in both. If this relationship is left to the caller, it gets rebuilt at every call site and forgotten somewhere. Here the component does not leave a hole; it locks the structure inside itself and exposes to the outside only a surface that takes a label text and an error message.
The criterion is this: if the structure carries correctness, it is wrapped; if it carries only appearance, it is left to a slot. What gets appended to the end of the badge is an appearance decision; how the label is bound to the field is a correctness decision. Both can be present in the same component: the badge’s core is locked, its end is open.
Summary
- Composition has two patterns: the wrapper component fixes the structure inside itself, slot-based composition takes part of the structure from the caller.
- The two patterns can produce the same tree; the difference is not in the output but in which file gets opened when a new request arrives.
- In a wrapper component built from boolean props, the number of options grows exponentially; five independent requests mean 32 combinations and five branches.
- Prop drilling is intermediate layers carrying props they do not use; its measure is the “forwarded-only prop” count, and it drops to zero in slot-based composition.
- A slot carries structure down, context carries data down; the two are not interchangeable.
- Structure that carries correctness is wrapped; structure that carries only appearance is left to a slot.
Next Step
This lesson solved the reuse of structure: there is now a way to build the same tree with different content. But structure is not the only thing reused in components. The search field in the filter panel triggers on a delay after input, the measurement table syncs its sort preference with the address bar, and the badge refreshes at an interval. All three contain state, a side effect, and cleanup; all three were written independently, and all three repeat the same mistakes. This logic cannot be moved as long as it stays bound to a component. The next lesson builds the unit that packages state and side effects without producing markup, and shows the rule that governs how these units attach to each other.
To keep your progress and take notes, Log in
My notes
Log in to take notes.