---
title: 'Component API Design'
source: 'https://academia.sh/en/courses/component-based-development/component-api-design'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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?

```js
// 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.
