Lesson 20 / 25
Component API Design
The merge rules for prop spreading, the cumulative combination of classes and listeners, reserved fields, the ownership decision between controlled and uncontrolled mode, and the backward compatibility of the prop surface.
Contents
Everything built in this topic rested on the component’s outer surface: which props it takes, which hole it leaves open, which handle it gives back. The surface itself has not been designed yet.
Four questions remain open. What happens when the caller gives the component an attribute it does not recognize? If the component’s own class name collides with the one the caller provides, which one wins? Can the component both listen to an event itself and call the caller’s listener? And who owns the displayed value — the component or the caller?
Spread Order
A component can pull out the props it recognizes for its own use and pass the rest through to the root it produces. This is called prop spreading, and it gives the caller an escape hatch: an attribute the component never thought of can be added without changing the component.
When spreading is done on its own, the result depends on the order of writing, and cumulative fields get lost.
// api.mjs — prop spreading, merge rules, and spread order const element = (name, attrs = {}, ...children) => ({ name, attrs, children }); // A small model of an event: a cancelable default. const makeEvent = (type) => ({ type, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }); // Chain two functions: first the outer one, then — if the default was not prevented — the inner one. const chain = (first, then) => (event) => { first?.(event); if (!event.defaultPrevented) then?.(event); }; // Merge rule: classes are appended, style merges key by key, a plain attribute has the // LAST one win, and a listener has the LAST one run FIRST (so the caller can cancel the component). function merge(...groups) { const result = {}; for (const group of groups) { for (const [name, value] of Object.entries(group ?? {})) { if (value === undefined) continue; if (name === "class") result.class = [result.class, value].filter(Boolean).join(" "); else if (name === "style") result.style = { ...(result.style ?? {}), ...value }; else if (/^on[A-Z]/.test(name) && typeof value === "function") { const previous = result[name]; result[name] = previous ? chain(value, previous) : value; } else result[name] = value; } } return result; } const log = []; const incoming = { class: "wide", style: { marginTop: "8px", color: "crimson" }, onClick: () => log.push("caller's listener"), "data-tracking": "badge-1", type: "submit", }; const own = { class: "badge-button", style: { color: "inherit", padding: "4px 8px" }, onClick: () => log.push("component's own listener"), type: "button", }; // (a) Plain spread: the last one wins, cumulative fields are lost. const plainCallerLast = { ...own, ...incoming }; const plainComponentLast = { ...incoming, ...own }; console.log("plain spread, caller last :", JSON.stringify({ class: plainCallerLast.class, style: plainCallerLast.style, type: plainCallerLast.type })); console.log("plain spread, component last:", JSON.stringify({ class: plainComponentLast.class, style: plainComponentLast.style, type: plainComponentLast.type })); // (b) Merge. const merged = merge(own, incoming); console.log("merge :", JSON.stringify({ class: merged.class, style: merged.style, type: merged.type })); log.length = 0; plainCallerLast.onClick(makeEvent("click")); console.log("\nlisteners that run with plain spread:", JSON.stringify(log)); log.length = 0; merged.onClick(makeEvent("click")); console.log("listeners that run with merge :", JSON.stringify(log)); // If the caller prevents the default, the component's listener does not run. log.length = 0; const cancelling = merge(own, { ...incoming, onClick: (e) => { log.push("caller: cancel"); e.preventDefault(); } }); cancelling.onClick(makeEvent("click")); console.log("when the caller cancels :", JSON.stringify(log)); // (c) Reserved fields: the caller cannot override the attributes that carry the component's correctness. const RESERVED = new Set(["role", "aria-controls", "id"]); function safeMerge(own, incoming) { const filtered = Object.fromEntries(Object.entries(incoming).filter(([a]) => !RESERVED.has(a))); const dropped = Object.keys(incoming).filter((a) => RESERVED.has(a)); return { attrs: merge(own, filtered), dropped }; } const { attrs, dropped } = safeMerge( { role: "tab", id: "filter-header-threshold", class: "tab", "aria-controls": "filter-body-threshold" }, { role: "button", id: "my-own-id", "aria-controls": "different-body", class: "highlighted", title: "Threshold settings" } ); console.log("\nreserved fields dropped:", JSON.stringify(dropped)); console.log("result:", JSON.stringify(attrs)); console.log("produced node:", JSON.stringify(element("button", attrs).attrs));
plain spread, caller last : {"class":"wide","style":{"marginTop":"8px","color":"crimson"},"type":"submit"}
plain spread, component last: {"class":"badge-button","style":{"color":"inherit","padding":"4px 8px"},"type":"button"}
merge : {"class":"badge-button wide","style":{"color":"crimson","padding":"4px 8px","marginTop":"8px"},"type":"submit"}
listeners that run with plain spread: ["caller's listener"]
listeners that run with merge : ["caller's listener","component's own listener"]
when the caller cancels : ["caller: cancel"]
reserved fields dropped: ["role","id","aria-controls"]
result: {"role":"tab","id":"filter-header-threshold","class":"tab highlighted","aria-controls":"filter-body-threshold","title":"Threshold settings"}
produced node: {"role":"tab","id":"filter-header-threshold","class":"tab highlighted","aria-controls":"filter-body-threshold","title":"Threshold settings"}
The first two lines show loss in both directions. When the caller is spread last, the
component’s own class and type value are erased; the button turns into a submit button
the caller never wanted. When the component is spread last, this time nothing the caller
gave survives, and the escape hatch closes.
The third line gives the result of the merge. Three fields are handled by three separate
rules. The class is appended: the component’s class comes first, the caller’s after;
since order is not decisive in CSS cascading, both stay in effect. Style merges key by
key: the color the caller supplies overrides the component’s, padding stays as the
component gave it, marginTop exists only on the caller’s side. In a plain attribute,
the last one wins: type ends up as the caller wanted.
Chaining Listeners
Picking a single value for listeners is always wrong: both the component’s own behavior and the caller’s request have to run. The second section of the output shows that with plain spreading the component’s listener is lost entirely, and with merging both run.
The chain’s order is a design decision, and it is the reverse of the rule for plain attributes. The caller’s listener runs first; if it prevents the default, the component’s own behavior never runs at all. The last line shows this: only the caller’s listener ran, the component’s was skipped. If the reverse order had been chosen, the caller could not have stopped the component, only added something after it.
The cancelability defined in the Default Behavior and Cancellation lesson in The Browser and the Web Platform course is carried up to the component level here: the component’s behavior is treated like a default the caller can cancel.
Reserved Fields
The escape hatch cannot be unlimited. In the final section of the output, the caller tries
to change the role, id, and aria-controls values; all three are dropped.
The distinction is the same criterion set up in the first lesson. title is a view
decision and passes through. role, on the other hand, is part of the component’s
correctness: if a tab header’s role is changed, the relationship between the header and the
body loses its meaning. aria-controls and id also carry the relationship the component
itself produces; the value the caller supplies breaks that relationship.
Dropping reserved fields silently is not correct either; printing a warning in development mode lets the caller learn where the escape hatch ends.
Who Owns the Value
The last question is the one that most often produces flaws: who holds the value the component displays?
// control.mjs — who owns the value: controlled and uncontrolled mode function fieldExample() { let internalState; let mode = null; const warnings = []; return { warnings, render({ value, initialValue = -5, onChange }) { const controlled = value !== undefined; if (mode === null) { mode = controlled ? "controlled" : "uncontrolled"; if (!controlled) internalState = initialValue; } else if ((mode === "controlled") !== controlled) { warnings.push(`mode changed: ${mode} → ${controlled ? "controlled" : "uncontrolled"}`); mode = controlled ? "controlled" : "uncontrolled"; if (!controlled) internalState = initialValue; } const shown = controlled ? value : internalState; return { mode, shown, write(next) { if (!controlled) internalState = next; onChange?.(next); }, }; }, }; } const row = (label, v) => console.log(`${label.padEnd(36)} ${String(v).padStart(6)}`); console.log("--- A. uncontrolled: the component owns the value ---"); const a = fieldExample(); let g = a.render({ initialValue: -5 }); row("first render (initialValue=-5)", g.shown); g.write(12); g = a.render({ initialValue: -5 }); row("after the user types 12", g.shown); g = a.render({ initialValue: 20 }); row("when initialValue is given as 20", g.shown); console.log("\n--- B. controlled: the value is the caller's, onChange bound ---"); const b = fieldExample(); let outerState = -5; const renderB = () => b.render({ value: outerState, onChange: (v) => { outerState = v; } }); g = renderB(); row("first render (value=-5)", g.shown); g.write(12); g = renderB(); row("after the user types 12", g.shown); outerState = 30; g = renderB(); row("when outer state moves to 30", g.shown); console.log("\n--- C. controlled but no onChange: a frozen field ---"); const c = fieldExample(); const renderC = () => c.render({ value: -5 }); g = renderC(); row("first render (value=-5)", g.shown); g.write(12); g = renderC(); row("after the user types 12", g.shown); console.log("\n--- D. mode change ---"); const d = fieldExample(); d.render({ initialValue: -5 }); d.render({ value: 7, onChange: () => {} }); d.render({ initialValue: -5 }); console.log("warnings:", JSON.stringify(d.warnings)); // The contract of a component that holds both modes in a single signature console.log("\nprop state mode who holds the value who reports the change"); for (const [value, onChange] of [[undefined, undefined], [undefined, "present"], [-5, undefined], [-5, "present"]]) { const controlled = value !== undefined; console.log( `${(`value=${value ?? "none"}, onChange=${onChange ?? "none"}`).padEnd(31)} ` + `${(controlled ? "controlled" : "uncontrolled").padEnd(13)} ` + `${(controlled ? "caller" : "component").padEnd(20)} ` + `${onChange ? "caller listens" : controlled ? "no one — value freezes" : "no one"}` ); }
--- A. uncontrolled: the component owns the value --- first render (initialValue=-5) -5 after the user types 12 12 when initialValue is given as 20 12 --- B. controlled: the value is the caller's, onChange bound --- first render (value=-5) -5 after the user types 12 12 when outer state moves to 30 30 --- C. controlled but no onChange: a frozen field --- first render (value=-5) -5 after the user types 12 -5 --- D. mode change --- warnings: ["mode changed: uncontrolled → controlled","mode changed: controlled → uncontrolled"] prop state mode who holds the value who reports the change value=none, onChange=none uncontrolled component no one value=none, onChange=present uncontrolled component caller listens value=-5, onChange=none controlled caller no one — value freezes value=-5, onChange=present controlled caller caller listens
Uncontrolled mode leaves the value to the component. The caller supplies only the
starting point; the rest is the component’s job. The last line of section A shows this
mode’s most surprising side: even though initialValue is made 20, the displayed value
stays at 12. The word “initial” is the contract itself — the prop is read only on the
first render. A prop whose name does not say “initial” cannot carry this behavior.
Controlled mode leaves the value to the caller. In section B, the component holds no value at all; what is displayed is always the incoming prop. When the outer state changes independently of the component, the view changes too — this is the reason for choosing controlled mode, because the value may need to change from somewhere else as well.
Section C shows what a single missing piece does. A value is given, no change handler is given; the user types, nothing happens. The field looks frozen, and the reason for it is written one line away in the source text. The component should report this combination in development mode.
Section D shows the second trap: when the same component instance changes mode, ownership of the value changes in the middle of its lifetime. This usually arises from the outer state being undefined on the first render. The contract wants the mode to stay fixed for the instance’s whole lifetime.
The Stability of the Surface
A component’s prop surface is a contract, and contract changes fall into two classes.
Adding a new prop that has a default is compatible; no caller is affected. Changing an existing prop’s default is not compatible: every caller that never touched its source text sees different behavior. Renaming a prop is also incompatible, but at least it is loud — the old name is no longer read and its effect shows up immediately.
The shape of the surface also affects stability. As seen in the first lesson, independent boolean props produce an exponential combination set; when mutually exclusive options are gathered into a single named prop, the set shrinks and adding a new option becomes a compatible change. The same criterion applies to callback functions: taking an object with named fields instead of a function that takes a single value allows fields to be added later.
Summary
- Prop spreading gives the caller an escape hatch; plain spreading, depending on write order, erases either what the component or what the caller supplies.
- Merging has three rules: the class is appended, style merges key by key, and in a plain attribute the last one wins.
- Listeners are chained, and the chain’s order is the reverse of plain attributes: the caller’s listener runs first, and if it prevents the default, the component’s behavior does not run.
- Fields that carry the component’s correctness are reserved; a dropped field is reported in development mode.
- In uncontrolled mode the value belongs to the component, in controlled mode to the caller; a prop carrying the “initial” prefix is read only on the first render, the mode must stay fixed for the instance’s lifetime, and giving a value without a change handler freezes the field.
- Adding a new prop with a default is a compatible change, changing an existing default is not.
Next Step
Throughout this topic, components were composed on paper: a function produced a tree, trees nested inside each other, props merged. How the produced tree gets reflected onto the document was never examined — which nodes get touched when state changes, how much comparison is done, and who does that work were left open. The answer to these questions does not depend on composition patterns but on the framework’s update model, and it differs from family to family. The next topic takes up these models along with their properties, and in the first family — the approach that produces a new tree from state and compares it against the previous one — counts how many units of work a single-cell change produces.
To keep your progress and take notes, Log in
My notes
Log in to take notes.