---
title: 'Component-Based Interface Development'
source: 'https://academia.sh/en/courses/component-based-development'
language: en
updated: '2026-08-17T12:50:59+00:00'
license: 'CC BY-SA 4.0'
lessons: 25
outcomes:
    - 'Explains the declarative interface model and the logic of re-rendering'
    - 'Builds a reusable interface through component composition'
    - 'Compares the design differences among framework families'
---

# Component-Based Interface Development

The declarative model that derives the interface from state instead of updating it step by step, the input-output contract and composition of components, reflecting a re-render onto the tree through reconciliation, and the design differences among framework families.

## The Declarative Interface Model

The scaling problems of manual updates, the one-way flow from state to view, the component's input-output contract, binding markup to data, identity preservation in conditional and list rendering, and the reconciliation algorithm.

### Why a Framework

Source: https://academia.sh/en/courses/component-based-development/why-a-framework

The scaling problems of updating an interface by hand; how the number of update paths grows, keeping state in two copies, the self-repairing shape of drift, and the work an interface framework takes over.

The Browser and the Web Platform course turned the station page into a data structure
the program could reach: nodes were created, listeners were attached, changes were
written to the relevant element, and all of it was undone on teardown. The measurement
badge turned into a component, but keeping the fifteen badges on the dashboard in sync
with the measurement list still depends on hand-written update code. When a record is
added, the code itself has to compute which nodes will change.

This lesson measures why that computation turns into a source of errors as it grows.
The problem is not clumsy code; it is a structural property of **imperative** updates,
and it becomes unavoidable as the dashboard grows.

#### The Three Jobs of Manual Updates

Three separate code paths are written for every piece of the interface.

**Initial creation** sets up nodes, writes their attributes, and attaches listeners.
**Update** determines which fields of the existing nodes take a new value when state
changes. **Teardown** removes listeners, stops timers, and disconnects observers when a
piece is removed.

All three describe the same view, but all three are written in separate places. The
correctness condition is this: the tree produced by initial creation and the tree
reached through update for the same state must be identical. Nothing checks this
condition. When a class is added to initial creation but not to update, the page is
correct on first load and wrong after the first interaction.

#### The Number of Update Paths

The size of the update code is measured not by the number of pieces, but by **the
number of connections between a state field and a piece**. Whatever number of pieces a
field feeds, every handler that changes that field has to update that many places.

```js
// manual-update.mjs — update paths between a state field and an interface piece
// We declare which state fields each dashboard piece reads.
const DASHBOARD = {
  "badge.value": ["measurements"],
  "badge.color": ["measurements", "thresholds"],
  "table.rows": ["measurements", "filter"],
  "table.count": ["measurements", "filter"],
  "filter.selection": ["filter"],
  "title.time": ["lastUpdate"],
};

// When a state field changes, the update paths that must be hand-written:
// one path for every piece that reads that field.
function summary(title, pieces) {
  const table = new Map();
  for (const [piece, fields] of Object.entries(pieces))
    for (const field of fields) table.set(field, [...(table.get(field) ?? []), piece]);

  let total = 0;
  console.log(title);
  for (const [field, list] of [...table].sort()) {
    total += list.length;
    console.log(`  ${field.padEnd(14)} ${String(list.length).padStart(2)} paths  ${list.join(", ")}`);
  }
  console.log(`  ${String(Object.keys(pieces).length).padStart(2)} pieces, ` +
    `${String(table.size).padStart(2)} fields, ${String(total).padStart(2)} paths\n`);
  return { piece: Object.keys(pieces).length, field: table.size, path: total };
}

const before = summary("Dashboard — initial state", DASHBOARD);

// Two requests: (1) the measurement unit should be changeable — the unit
// affects both the rows and the badge; (2) the dashboard should also show
// a summary chart and a threshold warning.
const after = summary("Dashboard — after two requests", {
  ...DASHBOARD,
  "badge.unit": ["unit"],
  "table.rows": ["measurements", "filter", "unit"],
  "chart.points": ["measurements", "filter", "unit"],
  "warning.strip": ["measurements", "thresholds", "filter"],
});

const percent = (a, b) => `${Math.round((a / b - 1) * 100)}%`;
console.log(`pieces ${percent(after.piece, before.piece)}, ` +
  `fields ${percent(after.field, before.field)}, paths ${percent(after.path, before.path)} increase`);
```

```
Dashboard — initial state
  filter          3 paths  table.rows, table.count, filter.selection
  lastUpdate      1 paths  title.time
  measurements    4 paths  badge.value, badge.color, table.rows, table.count
  thresholds      1 paths  badge.color
   6 pieces,  4 fields,  9 paths

Dashboard — after two requests
  filter          5 paths  table.rows, table.count, filter.selection, chart.points, warning.strip
  lastUpdate      1 paths  title.time
  measurements    6 paths  badge.value, badge.color, table.rows, table.count, chart.points, warning.strip
  thresholds      2 paths  badge.color, warning.strip
  unit            3 paths  table.rows, badge.unit, chart.points
   9 pieces,  5 fields, 17 paths

pieces 50%, fields 25%, paths 89% increase
```

The number of pieces grew by half, and the update paths that must be hand-written
nearly doubled. The reason is that the new pieces also read existing fields: once the
chart bound to the `measurements`, `filter`, and `unit` fields, every handler that
changes those three fields needed one more line.

In the worst case, the number of connections approaches the product of the number of
fields and the number of pieces. This product quickly stops being a number one person
can hold in their head; whoever adds a new piece to the page has to know who changes
every field that piece reads, and where.

#### Two Copies of State

In manual updates, state lives in two places: the program's variables and the tree
itself. The copy in the tree is incomplete and lossy.

The tree only stores strings. A number written as an attribute loses its type;
recovering the difference between `-4.2` and `"-4.2"` is the reading code's job. The
tree never stores state that has no visible effect on screen: if the selection in the
filter panel has been reduced to a class name, the reason for the selection or its
previous value is lost.

This yields a rule: **the tree is not a store, it is an output.** Code that makes
decisions by reading from the tree uses its own output as input, and it makes the wrong
decision at every divergence between the two copies.

#### The Anatomy of Drift

The divergence of the two copies is called **drift**. The model below catches drift
with an audit that compares the tree's text against the text expected from state.

```js
// drift.mjs — catches the moment state and the tree diverge in a manual update
// A small tree model: every node has a name and a text.
const tree = {
  "table.count": { text: "" },
  "table.rows": { text: "" },
  "filter.selection": { text: "" },
};
const write = (name, text) => { tree[name].text = text; };

const state = {
  measurements: [
    { name: "Temperature", kind: "temperature", value: -4.2 },
    { name: "Relative humidity", kind: "humidity", value: 72 },
    { name: "Wind speed", kind: "wind", value: 11.4 },
    { name: "Snow depth", kind: "snow", value: 38 },
  ],
  filter: "all",
  threshold: 50,
};

// Text expected from state. The audit compares this against what is in the tree.
const visible = (s) =>
  s.filter === "all" ? s.measurements : s.measurements.filter((m) => m.value >= s.threshold);
const expected = (s) => ({
  "table.count": `${visible(s).length} / ${s.measurements.length} measurements`,
  "table.rows": visible(s).map((m) => m.name).join(" | "),
  "filter.selection": s.filter,
});

// Initial setup: every node is written by hand.
for (const [name, text] of Object.entries(expected(state))) write(name, text);

// Event handlers write straight to the tree. Both look complete.
function filterChanged(next) {
  state.filter = next;
  write("filter.selection", next);
  write("table.rows", visible(state).map((m) => m.name).join(" | "));
  write("table.count", `${visible(state).length} / ${state.measurements.length} measurements`);
}
function measurementAdded(measurement) {
  state.measurements.push(measurement);
  write("table.rows", visible(state).map((m) => m.name).join(" | "));
  // "table.count" is not written here: whoever wrote the add path did not see the count.
}

function audit(label) {
  const e = expected(state);
  const drifted = Object.keys(e).filter((name) => tree[name].text !== e[name]);
  console.log(`${label}: ${drifted.length ? "drift" : "consistent"}`);
  for (const name of drifted)
    console.log(`  ${name}\n    tree    : ${tree[name].text}\n    expected: ${e[name]}`);
}

audit("setup");
filterChanged("aboveThreshold");
audit("filter changed");
measurementAdded({ name: "Pressure", kind: "pressure", value: 964 });
audit("measurement added");
filterChanged("aboveThreshold");
audit("filter reselected");
```

```
setup: consistent
filter changed: consistent
measurement added: drift
  table.count
    tree    : 1 / 4 measurements
    expected: 2 / 5 measurements
filter reselected: consistent
```

Three places in the output should be read.

First, the error arises from **what the add path omits**, not from the add code being
wrong. `measurementAdded` writes the line it does write correctly; the line it does not
write is the problem. This kind of omission is not caught by a compiler, a type
checker, or a unit test, because there is no uncalled function to point to.

Second, drift **repairs itself**. When the filter is reselected, the count takes the
correct value and consistency returns. A self-repairing error is the hardest kind to
reproduce: no one who does not follow the user's exact sequence of steps will ever see
it.

Third, the same text has been computed in **three separate places** — in the initial
setup, in the filter handler, and in the audit. This repetition is the source of the
drift. If the text were computed in a single place, a missing update would be
impossible.

#### Order, Identity, and Cleanup

Three more problems appear when list updates are hand-written.

**Order.** When a record enters the middle of a list, the calling code has to compute
which node moves where. The easy path is to delete the list and rebuild it; this loses
the focused input field, resets the scroll position, and cuts off transitions in
progress.

**Identity.** The answer to which record a node corresponds to is not written anywhere
in the tree. Nodes matched by index shift by one and pair with the wrong record when an
insertion happens at the start of the list. When this link is built by hand, it is
usually written to a data attribute and has to be updated on every reorder.

**Cleanup.** Every removed node's listener has to be removed; if it is not, the
detached nodes from the Memory Inspection lesson accumulate. Teardown code has to do
the exact reverse of creation code, and nothing checks that symmetry either.

#### The Work a Framework Takes Over

These problems share a single source: **the interface is defined by how it changes.**
Every line of code describes a transition; no line describes the result.

**A framework** is the infrastructure that reverses this definition. What gets written
is **what the interface should be** for a given state; finding the difference between
two states and applying it to the tree becomes the framework's job. In return, the
framework takes over four responsibilities:

- producing a view description from state,
- computing the difference between two view descriptions,
- writing that difference to the tree with the fewest operations,
- tearing down the listeners and resources of removed pieces.

In this course, the word **framework** is used only in this sense; it has no
connection to the link-layer frame in the Network Models and Protocols course or to the
embedded-content frame.

The handoff is not free. The diff computation happens at runtime and has a cost; the
framework imposes its own rules; a layer gets in the way while debugging. When this
cost is paid is measured in the course's final topic. The gain is what this lesson has
counted: update paths disappear, because now there is only one path.

#### Summary

- Manual updates require writing initial-creation, update, and teardown code
  separately for every piece; nothing checks that all three describe the same view.
- The size of the update code grows not with the number of pieces but with the number
  of connections between a state field and a piece; in the worst case, this number is
  the product of the field count and the piece count.
- The tree is an output, not a store: it stores no type beyond strings, and it never
  stores state with no visible effect.
- Drift arises from computing the same text in more than one place; its self-repairing
  shape turns it into an error that is hard to reproduce.
- Hand-written list updates add problems of order, identity, and cleanup.
- A framework replaces the definition of "how it changes" with the definition of
  "what it should be" and takes over the diff computation.

#### Next Step

The first item the framework takes over is producing a view description from state.
This is a stricter requirement than it sounds: the code that produces the description
must always return the same result for the same state, must never look at the tree's
current state, and must not change anything while it runs. The next lesson builds this
production as a function, shows what one-way flow means, and measures why regenerating
the entire view every time is correct but expensive.

### Declarative Rendering

Source: https://academia.sh/en/courses/component-based-development/declarative-rendering

The pure function that produces the view from state; the cycle of one-way flow, the three conditions of purity, the difference between a view description and the tree, and the cost of fully rebuilding the description.

The previous lesson counted the cost of manual updates: multiple update paths per
state field, the same text computed in more than one place, and the self-repairing
shape of drift between the two copies. The conclusion was that what gets written is not
how the interface changes, but what it should be.

This lesson builds that writing as a function. The conditions the function must meet
are strict: it must always return the same result for the same state, it must never
look at the document tree's current state, and it must not change anything while it
runs. When these three conditions hold, the interface becomes a consequence of state.

#### The View Is a Function

The declarative model is summarized in a single equation: **the view is a function of
state.** The input is the application's current state; the output is the complete
description of the interface in that state.

It matters that the output is not the document tree. The function produces a **view
description**: a plain data structure made of node names, attributes, and children.
The description draws nothing, is attached to nothing, and can be read, compared, and
copied.

```js
// view-function.mjs — pure function that produces a view description from state, and one-way flow
const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat() });

// View description: a plain data structure made of nodes, not a tree.
function view(state) {
  const visible = state.filter === "all"
    ? state.measurements
    : state.measurements.filter((m) => m.kind === state.filter);
  return element("section", { class: "dashboard" },
    element("h2", {}, `North Slope — ${visible.length} / ${state.measurements.length} measurements`),
    element("div", { class: "filter" },
      ["all", "temperature", "humidity"].map((s) =>
        element("button", { value: s, selected: s === state.filter }, s))),
    element("ul", { class: "table" },
      visible.map((m) =>
        element("li", { class: m.value >= m.threshold ? "badge exceeded" : "badge" },
          `${m.name}: ${m.value} ${m.unit}`))));
}

// Pure function that produces new state from an event: it does not mutate the old one, it returns a new one.
function nextState(state, event) {
  if (event.type === "filterSelected") return { ...state, filter: event.value };
  if (event.type === "measurementArrived")
    return { ...state, measurements: [...state.measurements, event.measurement] };
  return state;
}

const INITIAL = {
  filter: "all",
  measurements: [
    { name: "Temperature", kind: "temperature", value: -4.2, unit: "°C", threshold: 30 },
    { name: "Relative humidity", kind: "humidity", value: 72, unit: "%", threshold: 90 },
  ],
};
const EVENTS = [
  { type: "measurementArrived", measurement: { name: "Ground temperature", kind: "temperature", value: 1.8, unit: "°C", threshold: 30 } },
  { type: "filterSelected", value: "temperature" },
  { type: "measurementArrived", measurement: { name: "Dew point", kind: "humidity", value: 95, unit: "%", threshold: 90 } },
];

// Runs the flow from start to end; at every step, returns the state and the view description.
function run(initial, events) {
  let state = initial;
  const steps = [{ state, description: view(state) }];
  for (const event of events) {
    state = nextState(state, event);
    steps.push({ state, description: view(state) });
  }
  return steps;
}

const write = (d, indent = 0) => typeof d === "string"
  ? `${" ".repeat(indent)}"${d}"`
  : [`${" ".repeat(indent)}<${d.name}${Object.entries(d.attrs)
      .map(([k, v]) => ` ${k}=${JSON.stringify(v)}`).join("")}>`,
     ...d.children.map((c) => write(c, indent + 2))].join("\n");

const first = run(INITIAL, EVENTS);
console.log(write(first.at(-1).description));

// Same start and same event sequence: is the produced description byte-for-byte the same?
const second = run(INITIAL, EVENTS);
const sameEveryTime = first.every((a, i) =>
  JSON.stringify(a.description) === JSON.stringify(second[i].description));
console.log(`\nsame event sequence run again → are the descriptions the same: ${sameEveryTime}`);
console.log(`measurement count in the initial state (after three events): ` +
  `${INITIAL.measurements.length}`);
console.log(`visible measurements per step: ` +
  first.map((a) => a.description.children[2].children.length).join(", "));
```

```
<section class="dashboard">
  <h2>
    "North Slope — 2 / 4 measurements"
  <div class="filter">
    <button value="all" selected=false>
      "all"
    <button value="temperature" selected=true>
      "temperature"
    <button value="humidity" selected=false>
      "humidity"
  <ul class="table">
    <li class="badge">
      "Temperature: -4.2 °C"
    <li class="badge">
      "Ground temperature: 1.8 °C"

same event sequence run again → are the descriptions the same: true
measurement count in the initial state (after three events): 2
visible measurements per step: 2, 3, 2, 2
```

Three results should be read. When the same event sequence is run again, the produced
descriptions are identical; this means an error can be reproduced by recording the
event sequence. The initial state still holds two measurements even after three
events; the state was never mutated, a new one was produced at every step. The visible
measurement count changed step by step, but no one updated that number anywhere — it is
a consequence of the filter selection and the measurement list.

#### One-Way Flow

This arrangement is called **one-way data flow**. The cycle has four steps: state
produces a view description, the description is applied to the tree, user interaction
produces an event, the event produces a new state. The direction of the arrow never
reverses at any step.

The path that reverses is **two-way binding**: an interface piece can write directly to
the value it reads. The code gets shorter, but two consequences follow. First, where a
value comes from cannot be understood by looking at a single place; a field's value can
come from state or from itself. Second, a termination question arises between two
bindings that feed each other: A updates B, B updates A. One-way flow never raises this
question, because there is a single gate that changes state.

The cost of one-way flow shows up in elements that naturally carry their own value,
such as form fields. The field's value comes from state, the letter the user types
turns into an event, the event produces a new state, and the field takes its new value
from state. The path is long; its gain is that nothing but state determines the
field's value.

#### The Three Conditions of Purity

The view function must satisfy the condition defined under the name **pure function**
in the Programming Fundamentals course; in this course, its counterpart is the **pure
function**. In the interface context, the condition means three concrete prohibitions.

**Same input, same output.** Reading the current time, generating a random number, or
incrementing a counter inside the function causes the same state to produce two
different descriptions. If these values are genuinely needed, they are not produced
inside the function; they become a field of state and are computed outside. The text
"last measurement two minutes ago" is computed from a timestamp in state, not from a
clock read inside the function.

**No looking at the tree.** The function cannot read a value from the current document
tree and base its decision on it. Reading from the tree brings back the two-copies
problem from the previous lesson: the function ends up using its own output as input.
If a measured value is needed — the width of a box, for instance — that value is
measured, written to state, and used as input on the next render.

**No side effects.** The function does not start a network request, write to storage,
or set up a timer while it runs. The reason is not only cleanliness: a view description
may go unused after it is produced, may be produced more than once for the same state,
or may be produced in a different order. A function carrying a side effect behaves
unpredictably in these cases.

#### The Description Is Not the Tree

The reason this model works is that the description is cheap and the tree is
expensive.

A view description is a handful of objects and arrays; producing it amounts to nothing
more than allocating memory. A node in the document tree, by contrast, is a record that
feeds into the browser's style computation, layout calculation, and paint. This is why
the description can be regenerated on every state change, but the tree cannot be
rebuilt.

```js
// full-rebuild.mjs — the cost of rebuilding the entire description from scratch
const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat() });

function view(state) {
  return element("section", { class: "dashboard" },
    element("h2", {}, `North Slope — ${state.measurements.length} measurements`),
    element("input", { name: "search", value: state.search }),
    element("ul", { class: "table" },
      state.measurements.map((m) =>
        element("li", { class: "badge" }, `${m.name}: ${m.value} ${m.unit}`))));
}

// A real tree node: an object with identity that is costly to create.
let created = 0;
const newNode = (description) => {
  created++;
  if (typeof description === "string") return { text: description, id: created };
  return {
    name: description.name, attrs: { ...description.attrs }, id: created,
    children: description.children.map(newNode),
  };
};

let fieldsWritten = 0;
// Minimum operations: if the type is the same, the node is kept, only the changed field is written.
function apply(node, description) {
  if (typeof description === "string") {
    if (!node || node.text === undefined) return newNode(description);
    if (node.text !== description) { node.text = description; fieldsWritten++; }
    return node;
  }
  if (!node || node.name !== description.name) return newNode(description);
  for (const [k, v] of Object.entries(description.attrs))
    if (node.attrs[k] !== v) { node.attrs[k] = v; fieldsWritten++; }
  node.children = description.children.map((c, i) => apply(node.children[i], c));
  return node;
}

const MEASUREMENTS = Array.from({ length: 12 }, (_, i) => ({
  name: `Sensor ${String(i + 1).padStart(2, "0")}`, value: 10 + i, unit: "°C",
}));
const BEFORE = { search: "", measurements: MEASUREMENTS };
const AFTER = {
  search: "",
  measurements: MEASUREMENTS.map((m, i) => (i === 6 ? { ...m, value: 17.5 } : m)),
};

// Initial render: the tree is built from scratch on both paths.
created = 0;
const treeA = newNode(view(BEFORE));
const treeB = newNode(view(BEFORE));
const initialCost = created / 2;

// The user is typing into the search field; this information lives in the tree, not in the description.
const findField = (d) => d.name === "input" ? d : d.children?.map(findField).find(Boolean);
findField(treeA).focused = true;
findField(treeB).focused = true;

// Path A: the entire description is rebuilt from scratch.
created = 0;
const newA = newNode(view(AFTER));
console.log(`initial render                     : ${initialCost} nodes created`);
console.log(`full rebuild                       : ${created} nodes created, ` +
  `focus preserved: ${findField(newA).focused === true}`);

// Path B: the new description is applied to the existing tree.
created = 0; fieldsWritten = 0;
apply(treeB, view(AFTER));
console.log(`apply description to existing tree : ${created} nodes created, ` +
  `${fieldsWritten} fields written, focus preserved: ${findField(treeB).focused === true}`);
```

```
initial render                     : 29 nodes created
full rebuild                       : 29 nodes created, focus preserved: false
apply description to existing tree : 0 nodes created, 1 fields written, focus preserved: true
```

A single measurement's value has changed. Rebuilding the tree from scratch produces
twenty-nine nodes; applying the description to the existing tree writes a single field
and produces no nodes at all.

The cost difference is a secondary result. The real result is in the last column:
**state that lives in the tree and is absent from the description** disappears with a
rebuild. Focus is only one of these; scroll position, selected text range, transitions
in progress, a media element's playback position, and everything inside a shadow tree
belong to the same group. The view description carries none of these, because none of
them is part of state.

#### The Separation of Re-render and Apply

From here comes a distinction that carries the rest of the course.

**Re-render** is running the view function again. It is cheap and only produces a new
description; it changes nothing on screen.

**Apply** is comparing the new description against the tree and writing the
difference. This is the expensive part, and this is what needs to be minimized.

The misconception that "the declarative model rebuilds everything" conflates these
two. What gets re-rendered is the description; the tree is not rebuilt, it is patched.
The `apply` function above is the plainest form of this patching, and it has one gap: it
matches children only by their order. When a record is added to the start of the list,
every match shifts. The name and the fix for this gap are in the topic's last two
lessons.

#### Summary

- In the declarative model, the view is a function of state; the function's output is
  not the document tree but a view description made of plain data.
- One-way flow has four steps: state, description, event, new state. Two-way binding
  adds a back edge to this cycle and makes the source of a value ambiguous.
- The view function must be pure: same input, same output; no reading from the tree;
  no side effects. Clock reads and measured values are not produced inside the
  function; they are written to state.
- Producing a description is cheap, producing a tree node is expensive; the model is
  built on this asymmetry.
- Rebuilding the tree from scratch destroys state absent from the description —
  focus, scroll, selection, transitions in progress; applying the description to the
  existing tree preserves it.
- Re-render produces the description, apply writes the difference to the tree; the two
  operations are separate costs.

#### Next Step

This lesson's view function was a single piece: one function described the entire
dashboard. When fifteen badges, a table, and a filter panel fit into the same body,
that function becomes unreadable and none of its parts can be reused on its own. The
next lesson splits the function into pieces and gives each piece a contract: the input
it takes from outside, the event it reports to the outside, and the local state that
concerns only itself.

### The Component Concept

Source: https://academia.sh/en/courses/component-based-development/component-concept

Splitting the view function into pieces; the contract made of a prop, an output callback, and local state, the identity of a component instance, the area re-render spreads through, and which component a piece of state should live in.

The previous lesson's view function described the entire dashboard in a single body.
When fifteen badges, a measurement table, and a filter panel fit into the same
function, two things are lost at once: the body becomes unreadable and none of its
parts can be reused on its own.

This lesson splits the function into pieces. The unit of the split is called a
**component**: a function that produces its own view description and speaks to the
outside through three defined channels.

#### The Component's Three Channels

A component's relationship with the outside world is limited to three channels.

**A prop** is a value the parent gives to the child. When the context is clear, it is
also called a **property** for short. Its direction is one-way and it is read-only:
the child cannot change the value it receives. A measurement badge's input is the
measurement's name, value, unit, and threshold.

**Output** is the event the child reports to the parent. The child does not make a
decision; it reports what happened upward, and the side holding state makes the
decision. The filter panel reports which option was clicked; it does not decide which
filter gets applied. The carrier of the report is a **callback** given as input.

**Local state** is a value that concerns only the component itself and is invisible
from outside. Whether a badge's detail section is open is an example: no other piece
needs this information.

The entire contract is these three channels. A component has no other path to its
parent, no direct access to a sibling, and no hidden value it reads from the tree.

#### The Component Tree and Instance Identity

Components use one another and form a tree. This tree is not the document tree: a
component does not have to correspond to a document node, and a single component can
produce dozens of nodes.

Every position in the tree corresponds to a **component instance**. The instance is
where local state lives, and it is preserved across re-renders.

```js
// component-runtime.mjs — a small component model with input, output, and local state
const element = (name, attrs = {}, ...children) =>
  ({ kind: "element", name, attrs, children: children.flat() });
const component = (fn, props = {}) => ({ kind: "component", fn, props });

const instances = new Map();          // id → { local, fn, props }
let renderCount = new Map();          // component name → render count

function session(id) {
  const record = instances.get(id);
  return {
    // Local state belongs to the instance; setting it re-renders only this instance.
    local(name, initial) {
      if (!record.local.has(name)) record.local.set(name, initial);
      return [record.local.get(name), (next) => {
        record.local.set(name, next);
        renderComponent(id, record.fn, record.props);
      }];
    },
  };
}

function renderComponent(id, fn, props) {
  if (!instances.has(id)) instances.set(id, { local: new Map() });
  const record = instances.get(id);
  record.fn = fn; record.props = props;
  renderCount.set(fn.name, (renderCount.get(fn.name) ?? 0) + 1);
  return renderNode(fn(Object.freeze(props), session(id)), id);
}

function renderNode(description, path) {
  if (typeof description === "string") return description;
  if (description.kind === "component")
    return renderComponent(`${path}/${description.fn.name}`, description.fn, description.props);
  return { name: description.name, attrs: description.attrs,
    children: description.children.map((c, i) => renderNode(c, `${path}/${i}`)) };
}

// --- Components -----------------------------------------------------------
// Input: value, unit, exceeded. Output: none. Local state: whether the detail is open.
function MeasurementBadge(props, { local }) {
  const [open, setOpen] = local("open", false);
  return element("div", { class: props.exceeded ? "badge exceeded" : "badge", open: () => setOpen(!open) },
    `${props.name}: ${props.value} ${props.unit}`,
    open ? element("small", {}, `threshold ${props.threshold} ${props.unit}`) : "");
}

// Input: options, selected. Output: onSelect callback.
function FilterPanel(props) {
  return element("div", { class: "filter" }, props.options.map((s) =>
    element("button", { selected: s === props.selected, click: () => props.onSelect(s) }, s)));
}

function MeasurementTable(props) {
  return element("ul", { class: "table" }, props.measurements.map((m) =>
    component(MeasurementBadge, { ...m, exceeded: m.value >= m.threshold })));
}

function Dashboard(props) {
  const visible = props.filter === "all"
    ? props.measurements : props.measurements.filter((m) => m.kind === props.filter);
  return element("section", { class: "dashboard" },
    element("h2", {}, `North Slope — ${visible.length} / ${props.measurements.length} measurements`),
    component(FilterPanel, { options: ["all", "temperature", "humidity"],
      selected: props.filter, onSelect: props.onFilterSelect }),
    component(MeasurementTable, { measurements: visible }));
}

// --- Run --------------------------------------------------------------------
const MEASUREMENTS = [
  { name: "Temperature", kind: "temperature", value: -4.2, unit: "°C", threshold: 30 },
  { name: "Relative humidity", kind: "humidity", value: 72, unit: "%", threshold: 90 },
  { name: "Ground temperature", kind: "temperature", value: 1.8, unit: "°C", threshold: 30 },
  { name: "Dew point", kind: "humidity", value: 95, unit: "%", threshold: 90 },
];
let state = { filter: "all", measurements: MEASUREMENTS };
const renderRoot = () => renderComponent("root", Dashboard, {
  ...state,
  onFilterSelect: (s) => { state = { ...state, filter: s }; renderRoot(); },
});
const report = (label) => {
  console.log(`${label.padEnd(30)} ${[...renderCount].map(([a, n]) => `${a} ${n}`).join(", ")}`);
  renderCount = new Map();
};

renderRoot();
report("initial render");

// The first badge's local state changes: only that instance re-renders.
const BADGE = [...instances.keys()].find((k) => k.endsWith("MeasurementBadge"));
console.log(`first badge instance: ${BADGE}`);
session(BADGE).local("open", false)[1](true);
report("one badge's local state");

// The dashboard's state changes: a re-render starting from the root.
instances.get("root").props.onFilterSelect("temperature");
report("the dashboard's state");

console.log(`\nthat badge's local state after the filter: ` +
  `${instances.get(BADGE).local.get("open")}`);
console.log(`registered component instances: ${instances.size}`);
```

```
initial render                 Dashboard 1, FilterPanel 1, MeasurementTable 1, MeasurementBadge 4
first badge instance: root/2/MeasurementTable/0/MeasurementBadge
one badge's local state        MeasurementBadge 1
the dashboard's state          Dashboard 1, FilterPanel 1, MeasurementTable 1, MeasurementBadge 2

that badge's local state after the filter: true
registered component instances: 7
```

#### The Area Re-render Spreads Through

Four places in the output should be read.

First, **the instance's identity is derived from its position in the tree**. The path
`root/2/MeasurementTable/0/MeasurementBadge` points to the first badge of the table in
the dashboard's third child. Local state is tied to this identity; if the identity
changes, the state belongs to a different instance. This link breaks in lists, which
is the subject of the topic's fifth lesson.

Second, **local state re-renders only its own instance**. When the badge's detail
opened, a single render happened; the dashboard, the filter panel, and the table never
ran at all. This is the concrete payoff of keeping state as low as possible.

Third, **state above re-renders everything below it entirely**. When the filter was
selected, the render starting from the root ran five components. This is not an
inherent cost but a consequence of purity: every child whose input may have changed has
to be run again. The badge count stayed at two because the filter reduced the visible
measurements to two.

Fourth, **local state survives the re-render**. The badge ran again, but its `open`
value was preserved; state lives in the instance, not inside the function.

The last line also shows a gap: even though the filter removed two badges from the
tree, seven instances remain registered. Removing the instance and resources of a
component that leaves the tree is the framework's teardown responsibility; this model
does not do it.

#### Input Cannot Be Written, State Has One Owner

Two clauses of the contract are frequently violated: the child writes to its input, or
state that should be shared is kept inside the child.

```js
// contract.mjs — input immutability and the owner of state
// 1) Input is read-only. The framework enforces the contract by freezing the input.
function MeasurementBadge(props) {
  props.value = Math.round(props.value);   // contract violation
  return `${props.name}: ${props.value}`;
}
try {
  MeasurementBadge(Object.freeze({ name: "Temperature", value: -4.2 }));
} catch (error) {
  console.log(`writing to input: ${error.constructor.name} — ${error.message}`);
}

// 2) The invariant "at most one badge selected" depends on where state lives.
const NAMES = ["Temperature", "Relative humidity", "Wind speed"];
const CLICKS = ["Temperature", "Relative humidity", "Wind speed"];

// A) State inside each badge: the badge toggles its own selection.
const badgesA = NAMES.map((name) => ({ name, selected: false }));
const badgeA = (badge) => { badge.selected = !badge.selected; };
for (const name of CLICKS) badgeA(badgesA.find((b) => b.name === name));

// B) State in the common ancestor: the badge only reports the event upward.
let dashboard = { selectedName: null };
const badgeB = (props) => props.onSelect(props.name);
for (const name of CLICKS)
  badgeB({ name, selected: dashboard.selectedName === name,
    onSelect: (n) => { dashboard = { selectedName: dashboard.selectedName === n ? null : n }; } });

const selectedA = badgesA.filter((b) => b.selected).map((b) => b.name);
const selectedB = dashboard.selectedName ? [dashboard.selectedName] : [];
console.log(`\nclick sequence: ${CLICKS.join(" → ")}`);
console.log(`A) state in badges     → selected: [${selectedA}] (${selectedA.length} total)`);
console.log(`B) state in ancestor   → selected: [${selectedB}] (${selectedB.length} total)`);
console.log(`invariant ("at most one selected") A: ${selectedA.length <= 1}, ` +
  `B: ${selectedB.length <= 1}`);

// In A, the dashboard cannot write "1 badge selected" in its heading: the count is scattered across the badges.
console.log(`\nselection count the dashboard can read — A: none, B: ${selectedB.length}`);
```

```
writing to input: TypeError — Cannot assign to read only property 'value' of object '#<Object>'

click sequence: Temperature → Relative humidity → Wind speed
A) state in badges     → selected: [Temperature,Relative humidity,Wind speed] (3 total)
B) state in ancestor   → selected: [Wind speed] (1 total)
invariant ("at most one selected") A: false, B: true

selection count the dashboard can read — A: none, B: 1
```

The first section shows why writing to input is forbidden. If the write had
succeeded, the change would have propagated to the parent's object, and because the
parent never changed its state, no re-render would have been triggered: state would
have changed, the view would not. This is the same drift as in the first lesson.
Freezing the input object turns the violation into an error with a known location, not
a silent problem.

The second section shows that where state lives is a correctness question, not a
preference. When the selected state is scattered across the badges, no place remains
to preserve the "at most one badge selected" invariant; three clicks produce three
selected badges. When it is kept in a common ancestor, the same click sequence leaves a
single selection, because there is a single place that writes the value.

The last line adds a second result: scattered state is also **unreadable**. A
dashboard that wants to write how many badges are selected in its heading cannot reach
that number under arrangement A.

#### Where State Should Live

The decision comes down to two criteria.

If a value is read and written by **only one component**, it is that component's
local state. State kept lower re-renders a smaller area and keeps the component usable
on its own.

If a value is read or written by **more than one component**, it moves to their
**nearest common ancestor**. This move is called **lifting state up**. The ancestor
holds the value, gives it to the children as input, and updates it with the events the
children report.

Both criteria rest on the same principle: every value must have a single owner.
Keeping the same information in two places is the component-to-component form of the
drift seen between the tree and state in the first lesson.

Lifting has a cost: every layer between the value and the component that uses it has
to carry the input through. When the inputs that intermediate layers carry only to pass
along pile up, a different way of supplying a value across the tree becomes necessary;
this way is covered in a later topic of the course.

#### Summary

- A component is a view function that speaks to the outside through three channels: a
  read-only prop, output reported upward, and local state invisible from outside.
- The component tree is separate from the document tree; the component instance
  corresponding to every position in the tree is where local state lives and is
  preserved across re-renders.
- Local state re-renders only its own instance; state above re-runs every component
  below it.
- Writing to input leads to drift because it changes state without triggering a
  re-render; freezing the input catches the violation early.
- State that concerns more than one component is kept in the nearest common ancestor;
  scattered state can neither preserve the invariant nor be read from above.
- Every value must have a single owner; the cost of lifting is that intermediate
  layers carry the input only to pass it along.

#### Next Step

This lesson's components built the view description with function calls: every
element a call, every attribute an object field. When a measurement table's entire
structure is written this way, the shape of the markup is lost; reading where each
element closes among nested calls becomes hard. The next lesson covers writing markup
in its own syntax and binding it to data: interpolating an expression into text, taking
an attribute's value from an expression, binding an event to a function, and
determining at which point these require escaping.

### Template and Expression Syntax

Source: https://academia.sh/en/courses/component-based-development/template-and-expression-syntax

Binding markup to data; compiling a template once and running it many times, separating static and dynamic parts, the scope and limits of expressions, text escaping, and the distinction between attribute and property binding.

The previous lesson's components built the view description with nested function
calls. When a measurement table's entire structure is written this way, the shape of
the markup is lost: where each element closes, and which attribute belongs to which
element, do not become readable among the call parentheses.

This lesson covers writing markup in its own syntax and binding it to data. The
mechanism to be built is called a **template**: a markup text that carries
expressions, and a compilation step that turns it into a function producing a view
description.

#### Two Writing Families

There are two ways to bring markup into a program, and framework families split along
this line.

In the first way, **markup is embedded into the language**: the programming
language's syntax is extended, and markup becomes an expression of the language.
There is no separate concept of an expression slot; everything inside the markup is
already an expression of the language. The gain is that the full range of the
language's capabilities can be used directly.

In the second way, **the template is a separate text**: markers specific to markup are
added, and this text is compiled into a function. The template language is smaller
than the language; it allows only expressions and a handful of directives. The gain is
that the small language is analyzable: the compiler knows which part is static and
which is dynamic, and it can act on that knowledge.

Both ways arrive at the same place: a function that produces a view description from
state. The difference is when this function is produced, and with how much
information.

#### Compiling the Template

The compiler below is a small example of the second way. The template is parsed once,
and the result turns into a command tree; only this tree runs on every render.

```js
// template-compiler.mjs — compiles template text into a function that produces a view
const TEMPLATE = `
<div class="badge" :state="value >= threshold ? 'exceeded' : 'normal'" @click="open">
  <span class="name">{{name}}</span>
  <span class="value">{{value}} {{unit}}</span>
</div>`.trim();

// 1) Lexical analysis: tags and text chunks.
// The tag pattern skips the ">" character inside quotes.
const TAG = /<\/[a-z][\w-]*>|<[a-z][\w-]*(?:\s+[:@]?[\w-]+(?:="[^"]*")?)*\s*>|[^<]+/g;
const tokenize = (text) => text.match(TAG) ?? [];

// 2) Splits text into static slices and expressions: "{{name}}: {{value}}" → 4 parts.
function splitText(text) {
  const parts = [];
  let last = 0;
  for (const match of text.matchAll(/\{\{([^}]+)\}\}/g)) {
    if (match.index > last)
      parts.push({ kind: "static", value: text.slice(last, match.index) });
    parts.push({ kind: "expression", source: match[1].trim() });
    last = match.index + match[0].length;
  }
  if (last < text.length) parts.push({ kind: "static", value: text.slice(last) });
  return parts;
}

// 3) Splits attributes into three: static, bound (:), and event (@).
function splitAttributes(tag) {
  const staticAttrs = {}, bound = {}, event = {};
  for (const [, name, value] of tag.matchAll(/([:@]?[\w-]+)="([^"]*)"/g)) {
    if (name.startsWith(":")) bound[name.slice(1)] = value;
    else if (name.startsWith("@")) event[name.slice(1)] = value;
    else staticAttrs[name] = value;
  }
  return { static: staticAttrs, bound, event };
}

// 4) Compilation: the template is parsed once, the result turns into a command tree.
function compile(template, scopeNames) {
  const evaluate = (source) =>
    new Function(...scopeNames, `return (${source});`);
  const stack = [{ children: [] }];
  let staticCount = 0, dynamicCount = 0;

  for (const chunk of tokenize(template)) {
    if (chunk.startsWith("</")) { stack.pop(); continue; }
    if (chunk.startsWith("<")) {
      const name = chunk.match(/^<([a-z]+)/)[1];
      const { static: staticAttrs, bound, event } = splitAttributes(chunk);
      staticCount += Object.keys(staticAttrs).length;
      dynamicCount += Object.keys(bound).length + Object.keys(event).length;
      const node = {
        name, static: staticAttrs, children: [],
        bound: Object.fromEntries(Object.entries(bound)
          .map(([k, v]) => [k, evaluate(v)])),
        event: Object.fromEntries(Object.entries(event)
          .map(([k, v]) => [k, evaluate(v)])),
      };
      stack.at(-1).children.push(node);
      stack.push(node);
      continue;
    }
    if (!chunk.trim()) continue;
    for (const p of splitText(chunk.trim())) {
      if (p.kind === "static") { staticCount++; stack.at(-1).children.push(p.value); }
      else { dynamicCount++; stack.at(-1).children.push(evaluate(p.source)); }
    }
  }
  console.log(`compile: ${staticCount} static parts, ${dynamicCount} dynamic parts`);
  return { root: stack[0].children[0], scopeNames };
}

// 5) Execution: command tree + scope → view description.
function produce({ root, scopeNames }, scope) {
  const args = scopeNames.map((name) => scope[name]);
  const out = (d) => {
    if (typeof d === "string") return d;
    if (typeof d === "function") return String(d(...args));
    return {
      name: d.name,
      attrs: { ...d.static,
        ...Object.fromEntries(Object.entries(d.bound)
          .map(([k, f]) => [k, f(...args)])),
        ...Object.fromEntries(Object.entries(d.event)
          .map(([k, f]) => [`on:${k}`, f(...args).name || "function"])) },
      children: d.children.map(out),
    };
  };
  return out(root);
}

const SCOPE = ["name", "value", "unit", "threshold", "open"];
const compiled = compile(TEMPLATE, SCOPE);

const write = (d, indent = 0) => typeof d === "string"
  ? `${" ".repeat(indent)}${JSON.stringify(d)}`
  : [`${" ".repeat(indent)}<${d.name}${Object.entries(d.attrs)
      .map(([k, v]) => ` ${k}=${JSON.stringify(v)}`).join("")}>`,
     ...d.children.map((c) => write(c, indent + 2))].join("\n");

for (const measurement of [
  { name: "Temperature", value: -4.2, unit: "°C", threshold: 30, open: () => {} },
  { name: "Relative humidity", value: 94, unit: "%", threshold: 90, open: () => {} },
]) console.log(`\n${write(produce(compiled, measurement))}`);

// An expression sees only the declared scope names.
try {
  new Function(...SCOPE, "return (exceededCount);")("x", 0, "", 0, null);
} catch (error) {
  console.log(`\nname not in scope: ${error.constructor.name} — ${error.message}`);
}
```

```
compile: 4 static parts, 5 dynamic parts

<div class="badge" state="normal" on:click="open">
  <span class="name">
    "Temperature"
  <span class="value">
    "-4.2"
    " "
    "°C"

<div class="badge" state="exceeded" on:click="open">
  <span class="name">
    "Relative humidity"
  <span class="value">
    "94"
    " "
    "%"

name not in scope: ReferenceError — exceededCount is not defined
```

The output has four results.

**Compile once, run many times.** The parsing line was written only once; the template
text was never looked at again while producing the view for two different
measurements. The same holds for fifteen badges. Parsing the template is not part of
the render cost.

**Static and dynamic parts are separated.** The compiler knows that four parts depend
on no state at all. This knowledge is used in the next lesson to reduce cost: the parts
that never need comparing are known in advance.

**An expression is evaluated in the declared scope.** The name `value` in the
template is the `value` variable in the component's scope; a name absent from the
scope produces an error at runtime. The names the template sees are limited to the
names the component supplies.

**The three binding types are written with separate markers.** An attribute without a
prefix is static; the colon prefix takes its value from an expression; the at-sign
prefix binds an event. Having the distinction visible in the syntax lets the compiler
treat each one differently.

#### The Limits of Expressions

Template expressions run during rendering, and on every render. Two rules follow from
this.

**An expression must not produce a side effect.** The previous lesson's purity
condition applies to the template as well: an expression that changes state, starts a
request, or increments a counter behaves depending on the render count.

**An expression must be cheap.** Sorting or filtering a list inside the template is
recomputed on every render. Doing that computation once and caching its result is the
subject of the course's derived-values lesson.

Keeping the template language limited to expressions supports these rules at the
syntax level too: building a loop, performing an assignment, or catching an error is
impossible in a language where statements cannot be written. That work belongs to the
component's body.

#### Text, Attribute, and Event Binding

The behavior of the three binding types differs, and confusing them produces two
familiar problems.

```js
// binding-types.mjs — text escaping and the difference between attribute/property binding
// 1) Text interpolation escapes; raw insertion does not.
const escape = (s) => String(s)
  .replace(/&/g, "&amp;").replace(/</g, "&lt;")
  .replace(/>/g, "&gt;").replace(/"/g, "&quot;");

const note = 'Sensor <b>suspicious</b> reading';       // free text entered into a form
console.log(`text interpolation : <p>${escape(note)}</p>`);
console.log(`raw insertion      : <p>${note}</p>`);

const attrValue = 'snow" data-access="full';           // value carrying a quote
console.log(`\nattribute (escaped)  : <li type="${escape(attrValue)}">`);
console.log(`attribute (unescaped): <li type="${attrValue}">`);
const attributeCount = (s) => [...s.matchAll(/[\w-]+="/g)].length;
console.log(`attributes produced — escaped: ` +
  `${attributeCount(`<li type="${escape(attrValue)}">`)}, ` +
  `unescaped: ${attributeCount(`<li type="${attrValue}">`)}`);

// 2) An attribute is the document's string; a property is the live value; they diverge once the user types.
function makeField() {
  const node = { attrs: {}, liveValue: "", userTouched: false };
  return {
    node,
    writeAttribute(value) {
      node.attrs.value = String(value);
      if (!node.userTouched) node.liveValue = String(value); // initial value
    },
    writeProperty(value) { node.liveValue = String(value); },
    userTypes(text) { node.userTouched = true; node.liveValue = text; },
  };
}

for (const type of ["attribute", "property"]) {
  const field = makeField();
  const write = (v) => type === "attribute" ? field.writeAttribute(v) : field.writeProperty(v);
  write("snow");                        // initial render: search text from state
  field.userTypes("snow de");           // user is typing
  write("snow depth");                  // state updated, re-render
  console.log(`\n${type} binding → value in document: ` +
    `${JSON.stringify(field.node.attrs.value ?? null)}, ` +
    `visible in field: ${JSON.stringify(field.node.liveValue)}`);
}
```

```
text interpolation : <p>Sensor &lt;b&gt;suspicious&lt;/b&gt; reading</p>
raw insertion      : <p>Sensor <b>suspicious</b> reading</p>

attribute (escaped)  : <li type="snow&quot; data-access=&quot;full">
attribute (unescaped): <li type="snow" data-access="full">
attributes produced — escaped: 1, unescaped: 2

attribute binding → value in document: "snow depth", visible in field: "snow de"

property binding → value in document: null, visible in field: "snow depth"
```

**Text interpolation escapes.** Text the user enters stays text; the markup
characters inside it are converted to the character references from the Web
Fundamentals and HTML course. This is the template syntax's default behavior, and it
is a deliberate security decision. The **raw insertion** path the same template
language offers turns this protection off: the value is interpreted as markup. Raw
insertion's input should never come from the user.

**Attribute binding also escapes.** An unescaped value closes the quote and opens a
new attribute: the example produced two attributes instead of one. The reason the
template compiler can choose the correct escaping is that it knows where it is placing
the value; the same value is escaped by different rules inside text, inside an
attribute, and as an address value.

**Attribute and property are not the same thing.** The distinction built in the DOM
API lesson turns into a choice in the template. Writing an attribute changes the
string in the document; once the user has touched the field, it no longer tracks the
field's live value. The output's last two lines show this: the attribute path updates
the value in the document, but the text visible in the field stays what the user
typed. The property path updates the live value.

The rule follows from this: **bindings that carry the displayed value are written as
a property; bindings that carry metadata in the document are written as an
attribute.** Boolean attributes are a separate case: they carry presence or absence in
the document, and true or false on the program side; writing a boolean attribute with
a false value is not the same thing as removing it.

**Event binding binds a function.** The marker `@click="open"` in the template does
not produce a string; it produces a reference to a function found in the scope, and
that reference is what shows up in the output as `on:click="open"`. Interpreting the
value as a string would mean writing code inside the markup.

#### Summary

- A template is markup written in its own syntax and bound to data; the compilation
  step turns it into a function that produces a view description.
- Markup is either embedded into the language or compiled as a separate text; in the
  second way, the compiler can tell static and dynamic parts apart.
- The template is parsed once, and the produced command tree runs on every render;
  parsing is not part of the render cost.
- Template expressions are evaluated in the component's scope, must produce no side
  effect, and must be cheap; the inability to write statements supports this rule at
  the syntax level.
- Text interpolation and attribute binding escape by default; raw insertion does not,
  and its input should never come from the user.
- Bindings that carry the displayed value are written as a property, bindings that
  carry metadata in the document as an attribute; event binding binds a function
  reference, not a string.

#### Next Step

This lesson's template described a single badge, and its structure stayed the same in
every state: the same elements, in the same order. A real dashboard, by contrast, has
a variable structure — when the filter matches no measurement, an empty-state message
appears in place of the table, the measurement list grows and shrinks, rows get
reordered. The next lesson adds conditional and iteration directives to the template
and asks the real question: when the list changes, what determines which row in the
new description corresponds to which node in the tree?

### Conditional and List Rendering

Source: https://academia.sh/en/courses/component-based-development/conditional-and-list-rendering

Views with a variable structure; the difference between not producing an element and hiding it, the cost of index matching versus keyed matching in list updates, the criteria for choosing a key, and the preservation of identity.

The previous lesson's template produced the same structure in every state: the same
elements, in the same order. A real dashboard, by contrast, has a variable structure.
When the filter matches no measurement, a message appears in place of the table, the
measurement list grows and shrinks, rows get reordered.

This lesson adds conditionals and iteration to the template. Its second half is the
declarative model's most often skipped question: what determines which row in the new
description corresponds to which node in the tree?

#### Conditional Rendering

Conditional rendering has two forms. When the condition does not hold, either
**nothing is produced**, or **a different branch is produced**. A special case of the
second is the empty state: when the list is empty, a message is produced in place of
the list.

The decision here is between not producing an element and producing it and hiding it,
and the two are not equivalent.

An element that is not produced does not exist in the document tree: it is invisible
in the accessibility tree, its form fields are not submitted, its images are not
downloaded, and it takes no place in the tab order. An element hidden with a style
stays in the tree; depending on the hiding method, it may or may not leave the
accessibility tree, but its nodes and listeners keep living in memory.

The criterion is frequency of use. A piece that opens and closes often and is
expensive to set up — one of the tabs, the measurement chart — is produced and hidden,
so it is not rebuilt on every opening. A large piece that rarely appears is not
produced at all; shrinking the page's initial load is the gain.

The condition itself also raises an identity question. If two branches produce the
same type of element in the same position, the framework reads this as "same element,
content changed" and reuses the node. This leads to the problem measured at the end of
the lesson.

#### List Rendering and the Matching Problem

List rendering is producing a view description for every record of a data array.
Producing it is easy; the problem starts on the second render. Two lists now exist —
the old nodes in the tree and the records in the new description — and a decision has
to be made about which matches which.

There are two matching rules. **Index matching** binds the nth node to the nth
record. **Keyed matching** finds the node by a key each record carries.

```js
// list-matching.mjs — operation counts produced by index matching and keyed matching
const OLD = ["temperature", "humidity", "wind", "snow"];
const SCENARIOS = {
  "append at end":    [...OLD, "pressure"],
  "prepend at start": ["pressure", ...OLD],
  "remove from middle": OLD.filter((a) => a !== "wind"),
  "reorder":          ["snow", "temperature", "wind", "humidity"],
};

// A) Index matching: the nth old node matches the nth new record.
function indexMatch(old, next) {
  const common = Math.min(old.length, next.length);
  let contentWrites = 0;
  for (let i = 0; i < common; i++) if (old[i] !== next[i]) contentWrites++;
  return { create: Math.max(0, next.length - old.length),
    remove: Math.max(0, old.length - next.length), move: 0, contentWrites };
}

// Longest increasing subsequence of the old ordering: these nodes can stay in place.
function longestIncreasingSubsequence(array) {
  const tails = [];
  for (const value of array) {
    let lo = 0, hi = tails.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (tails[mid] < value) lo = mid + 1; else hi = mid;
    }
    tails[lo] = value;
  }
  return tails.length;
}

// B) Keyed matching: the node is found by the record's key.
function keyedMatch(old, next) {
  const oldIndex = new Map(old.map((a, i) => [a, i]));
  const remaining = next.filter((a) => oldIndex.has(a)).map((a) => oldIndex.get(a));
  return {
    create: next.filter((a) => !oldIndex.has(a)).length,
    remove: old.filter((a) => !next.includes(a)).length,
    move: remaining.length - longestIncreasingSubsequence(remaining),
    contentWrites: 0,
  };
}

const row = (label, s) => `${label.padEnd(16)} create ${s.create}  remove ` +
  `${s.remove}  move ${s.move}  contentWrites ${s.contentWrites}  → total ` +
  `${s.create + s.remove + s.move + s.contentWrites}`;

console.log(`old list: ${OLD.join(", ")}\n`);
for (const [name, next] of Object.entries(SCENARIOS)) {
  console.log(`${name}: ${next.join(", ")}`);
  console.log(`  ${row("index match", indexMatch(OLD, next))}`);
  console.log(`  ${row("keyed match", keyedMatch(OLD, next))}`);
}
```

```
old list: temperature, humidity, wind, snow

append at end: temperature, humidity, wind, snow, pressure
  index match      create 1  remove 0  move 0  contentWrites 0  → total 1
  keyed match      create 1  remove 0  move 0  contentWrites 0  → total 1
prepend at start: pressure, temperature, humidity, wind, snow
  index match      create 1  remove 0  move 0  contentWrites 4  → total 5
  keyed match      create 1  remove 0  move 0  contentWrites 0  → total 1
remove from middle: temperature, humidity, snow
  index match      create 0  remove 1  move 0  contentWrites 1  → total 2
  keyed match      create 0  remove 1  move 0  contentWrites 0  → total 1
reorder: snow, temperature, wind, humidity
  index match      create 0  remove 0  move 0  contentWrites 3  → total 3
  keyed match      create 0  remove 0  move 2  contentWrites 0  → total 2
```

On append at the end, the two rules give the same result: none of the old nodes
shifted.

On prepend at the start, the gap opens. Index matching rewrites the content of all
four nodes, because every node now matches the next record over; keyed matching
produces a single node and touches none of the others. The gap grows with the length
of the list: adding one row to the start of a hundred-record list produces a hundred
content writes under index matching.

On remove from the middle, index matching removes the last node and rewrites one of
the remaining nodes; keyed matching removes the correct node. The visible result is
the same, but the removed node is different — the importance of this distinction is in
the next section.

On reorder, keyed matching turns into moves. The minimum number of nodes that must
move is the number of nodes outside the longest increasing subsequence of the old
ordering; in a four-node list, that number is two.

#### Choosing the Key

A key has three conditions.

**Unique among siblings.** A key must be distinguishing only among siblings in the
same list, not across the whole page. A duplicate key means two records are asking for
the same node.

**Stable.** The same record must carry the same key across two renders. A random
value or a sequence number generated during rendering breaks this condition: every key
changes on every render, every node gets rebuilt, and the gain of using a key
reverses.

**Derived from data.** The key is the record's own identity; if no field
distinguishes the record, an identity is given to it when it is produced. Making a
visible field — the measurement's name — the key breaks stability once that field
becomes editable.

Using the index as the key satisfies the second of these conditions in exactly one
case: the list's order never changes, and there is no insertion or removal in the
middle. When a sort button appears tomorrow in a list where this condition holds
today, the error shows up in the sort button, not in the listing code.

#### Preservation of Identity

The real subject of matching is not the operation count. A node's identity is the
address of everything tied to the node that is absent from the view description.

```js
// identity-preservation.mjs — which record a key ties local state to
const RECORDS = [
  { id: "s1", name: "Temperature" },
  { id: "n1", name: "Relative humidity" },
  { id: "r1", name: "Wind speed" },
];

// Every row has a checkbox; the selection lives in the row's instance record.
function render(instances, records, getKey) {
  return records.map((record, index) => {
    const key = String(getKey(record, index));
    if (!instances.has(key)) instances.set(key, { selected: false });
    return { key, name: record.name, instance: instances.get(key) };
  });
}

const KEY_STRATEGIES = {
  "index": (record, index) => index,
  "record id": (record) => record.id,
};

for (const [name, getKey] of Object.entries(KEY_STRATEGIES)) {
  const instances = new Map();
  let rows = render(instances, RECORDS, getKey);
  rows.find((r) => r.name === "Wind speed").instance.selected = true;  // user checked it

  // A new measurement enters at the start of the list; the same components re-render.
  rows = render(instances, [{ id: "b1", name: "Pressure" }, ...RECORDS], getKey);
  const checked = rows.filter((r) => r.instance.selected).map((r) => r.name);
  console.log(`key = ${name.padEnd(15)} → checked row: ${checked.join(", ") || "none"}`);
}

// The same rule applies in conditional rendering too: same type in the same position, same instance.
function renderConditional(instances, mode, getKey) {
  const description = mode === "search"
    ? { kind: "input", role: "search" }
    : { kind: "input", role: "note" };
  const key = getKey(description, mode);
  if (!instances.has(key)) instances.set(key, { typed: "" });
  return { key, role: description.role, instance: instances.get(key) };
}

for (const [name, getKey] of Object.entries({
  "type only": (description) => description.kind,
  "type + branch": (description, mode) => `${description.kind}:${mode}`,
})) {
  const instances = new Map();
  renderConditional(instances, "search", getKey).instance.typed = "snow";  // user typed
  const note = renderConditional(instances, "note", getKey);
  console.log(`\ncondition key = ${name.padEnd(14)} → branch: ${note.role}, ` +
    `text in field: ${JSON.stringify(note.instance.typed)}, ` +
    `instance count: ${instances.size}`);
}
```

```
key = index           → checked row: Relative humidity
key = record id       → checked row: Wind speed

condition key = type only      → branch: note, text in field: "snow", instance count: 1

condition key = type + branch  → branch: note, text in field: "", instance count: 2
```

In the first section, the user checked the wind speed row. When a measurement enters
at the start of the list, the index key shifts the checkmark by one row, and the
checkmark shows up on the relative humidity row. When the record's id is the key, the
checkmark stays with its record.

This is not a data error: the state itself is correct; what is wrong is which record
the state is counted as belonging to. The same drift happens in a focused field, a
scroll position, an opened detail section, and a transition in progress. The visible
result for the user is that a row they never checked appears checked.

The second section is the same rule's counterpart in conditional rendering. When two
branches produce the same type of element in the same position, the match is built on
type, the instance is reused, and text typed into the search field keeps showing up in
the note field. When a key that distinguishes the branches is given, two separate
instances form and the text does not carry over.

A two-way rule follows from this. For things that should stay the same, the key is
kept **the same**; for things that should be separated, the key is **deliberately
changed**. The second direction is how a component's state gets reset: the component
is given a new key, the old instance is torn down, and a new instance is built from
scratch.

#### Summary

- In conditional rendering, not producing an element and hiding it are not
  equivalent: an element that is not produced is absent from the tree, the
  accessibility tree, and the tab order; a hidden element stays in memory. Expensive
  pieces that open and close often are produced and hidden; large pieces that rarely
  appear are not produced at all.
- Index matching rewrites the content of every node when an insertion happens at the
  start of the list; keyed matching produces a single node.
- On reorder, the minimum number of nodes that must move are the nodes outside the
  longest increasing subsequence of the old ordering.
- A key must be unique among siblings, stable across renders, and a value derived
  from data; the index satisfies this condition only in lists whose order never
  changes.
- When identity is not preserved, state absent from the view description gets tied to
  the wrong record: a checkbox, focus, scroll position, an open detail, and a
  transition in progress.
- Deliberately changing the key is how a component's state gets reset.

#### Next Step

This lesson built the matching rule at the list level, but left open what happens to
a node in the list after it is matched: how its attributes are compared, in what order
its children are visited, what happens when its type changes. The next lesson gathers
all these rules into a single algorithm, shows which assumptions make virtual-tree
comparison cheap, and sets the approaches that solve the same problem without
comparison alongside it.

### Reconciliation

Source: https://academia.sh/en/courses/component-based-development/reconciliation

Comparing the new view description against the previous one; the virtual tree, the three assumptions that make comparison linear, producing the patch list, and a measured comparison against approaches that update without comparing.

The previous lesson built the matching rule at the list level: a key determines which
record corresponds to which node. What happens after matching was left open — how
attributes are compared, in what order children are visited, what happens when the
type changes.

This lesson gathers all of these rules into a single algorithm. The algorithm is
called **reconciliation**: its input is two view descriptions, its output is the list
of operations to apply to the tree.

#### Reconciliation's Input and Output

The framework keeps the last view description it produced in memory. This stored
description is called the **virtual tree**: a copy of the document tree's current
state, made of plain data.

When state changes, a new description is produced and the two descriptions are
compared. The result of the comparison is a **patch list** — create a node, remove it,
move it, write an attribute, write text. Only this list is applied to the document
tree.

This is the reason the virtual tree exists. If the comparison were done on the
document tree, every step would read from the tree; reading from the tree can trigger
a layout calculation and produces the layout thrashing from the Performance Recording
lesson. Comparing over a copy made of plain data eliminates these reads.

#### Three Assumptions That Make Comparison Cheap

Finding the smallest sequence of transformations between two ordered trees is a job
of cubic order in tree size. It is too expensive to do on every frame. Reconciliation
reduces this problem to linear with three assumptions.

**The subtree of a node whose type changes is not compared.** If a section element
appears in a position where a list item used to be, the old subtree is discarded
entirely and the new one is built from scratch. The assumption's rationale is that two
elements of different types are expected to produce different structures. Its cost is
that switching between two different wrappers carrying the same content rebuilds the
entire subtree — and the state inside it.

**Comparison is done level by level.** Whether a node might have moved to a different
level is never tried. A row leaving a table and entering a disclosure panel is seen as
a removal and a creation, not a move.

**Siblings match by key.** The previous lesson's rule is the algorithm's
child-visiting step. Siblings without a key match by position.

#### The Algorithm

The implementation below applies these three assumptions and produces the patch list.

```js
// reconciliation.mjs — compares two view descriptions and produces a patch list
const element = (name, attrs = {}, children = [], key = null) =>
  ({ name, attrs, children, key });

function view(state) {
  return element("section", { class: "dashboard" }, [
    element("h2", {}, [`North Slope — ${state.measurements.length} measurements`]),
    element("ul", { class: "table" }, state.measurements.map((m) =>
      element("li", { class: m.value >= m.threshold ? "badge exceeded" : "badge" },
        [`${m.name}: ${m.value} ${m.unit}`], m.id))),
  ]);
}

// Longest increasing subsequence of the old ordering: nodes that can stay in place.
function longestIncreasingSubsequence(array) {
  const tails = [], prev = [];
  for (const [i, value] of array.entries()) {
    let lo = 0, hi = tails.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (array[tails[mid]] < value) lo = mid + 1; else hi = mid;
    }
    prev[i] = lo > 0 ? tails[lo - 1] : -1;
    tails[lo] = i;
  }
  const result = new Set();
  for (let i = tails.at(-1); i !== undefined && i !== -1; i = prev[i]) result.add(i);
  return result;
}

function reconcile(old, next, path, patches) {
  if (typeof old === "string" || typeof next === "string") {
    if (old !== next) patches.push(`TEXT     ${path}  ${JSON.stringify(next)}`);
    return;
  }
  // 1. If the type or key differs, the subtree is not compared; it is replaced with the new one.
  if (old.name !== next.name || old.key !== next.key) {
    patches.push(`REPLACE  ${path}  <${old.name}> → <${next.name}>`);
    return;
  }
  // 2. Attributes are compared in both directions.
  for (const [k, v] of Object.entries(next.attrs))
    if (old.attrs[k] !== v) patches.push(`ATTR     ${path}  ${k}=${JSON.stringify(v)}`);
  for (const k of Object.keys(old.attrs))
    if (!(k in next.attrs)) patches.push(`DEL-ATTR ${path}  ${k}`);
  // 3. Children: keyed ones match by key, unkeyed ones match by position.
  const oldIndex = new Map();
  old.children.forEach((c, i) =>
    oldIndex.set(typeof c === "string" || c.key === null ? `#${i}` : c.key, i));
  const matched = [];
  next.children.forEach((c, i) => {
    const key = typeof c === "string" || c.key === null ? `#${i}` : c.key;
    const oldIdx = oldIndex.get(key);
    if (oldIdx === undefined) patches.push(`CREATE   ${path}/${i}  ${key}`);
    else matched.push({ i, oldIdx, key });
  });
  for (const [key, i] of oldIndex)
    if (!matched.some((e) => e.key === key))
      patches.push(`REMOVE   ${path}/${i}  ${key}`);
  const keep = longestIncreasingSubsequence(matched.map((e) => e.oldIdx));
  matched.forEach((e, k) => {
    if (!keep.has(k)) patches.push(`MOVE     ${path}/${e.oldIdx} → ${path}/${e.i}  ${e.key}`);
    reconcile(old.children[e.oldIdx], next.children[e.i], `${path}/${e.i}`, patches);
  });
}

const M = (id, name, value, unit, threshold) => ({ id, name, value, unit, threshold });
const BEFORE = { measurements: [
  M("s1", "Temperature", -4.2, "°C", 30), M("n1", "Relative humidity", 72, "%", 90),
  M("r1", "Wind speed", 11.4, "m/s", 25), M("k1", "Snow depth", 38, "cm", 60),
] };
const AFTER = { measurements: [
  M("k1", "Snow depth", 38, "cm", 60), M("s1", "Temperature", -4.2, "°C", 30),
  M("n1", "Relative humidity", 94, "%", 90), M("b1", "Pressure", 964, "hPa", 1050),
] };

const patches = [];
reconcile(view(BEFORE), view(AFTER), "root", patches);
const countNodes = (description) => typeof description === "string"
  ? 1 : 1 + description.children.reduce((t, c) => t + countNodes(c), 0);
console.log(`nodes in the description: ${countNodes(view(AFTER))}`);
console.log(`patches produced        : ${patches.length}\n`);
console.log(patches.join("\n"));
```

```
nodes in the description: 12
patches produced        : 5

CREATE   root/1/3  b1
REMOVE   root/1/2  r1
MOVE     root/1/3 → root/1/0  k1
ATTR     root/1/2  class="badge exceeded"
TEXT     root/1/2/0  "Relative humidity: 94 %"
```

Four things changed at once: a measurement was added, one was removed, the list was
reordered, and one measurement's value crossed the threshold. Five patches were
produced for a twelve-node description.

**A single move is enough.** Even though the order of all four rows changed, the rows
found in the longest increasing subsequence of the old ordering were left in place,
and only the snow depth row was moved.

**Crossing the threshold produced two separate patches.** The relative humidity's
class and text are separate fields; each field goes through its own comparison. This
separation is kept because writing an attribute has a different cost than writing
text.

**The heading produced no patch at all.** Because the measurement count stayed at
four, the heading's text did not change; the comparison saw this and wrote no
operation. This is the difference between comparing a field and writing it: comparison
is cheap, writing is expensive.

**The changed measurement itself was never moved.** Because removal, creation, and
moving are resolved by key, the updated row stayed in its own node even though it
changed position in the list. Without a key, this row would have been rewritten too.

#### Where the Cost Lies

Reconciliation is linear in the size of the produced description. A practical
conclusion follows: the way to reduce cost is not to speed up the comparison but to
**produce a smaller description**.

What determines the size of the description is the re-render area measured in the
third lesson. When state at the root changes, the entire tree re-renders and the
entire description is produced and compared. Keeping state as low as possible,
skipping the production of subtrees whose input has not changed, and caching the
result of expensive computations are the tools that narrow this area; the last two are
covered in later topics of the course.

#### Approaches That Do Not Compare

Reconciliation resolves the question of "which value affects which node" all over
again every time. Solving the same question once and storing the answer is also
possible, and this is the fundamental design difference that separates framework
families.

```js
// approaches.mjs — the work three approaches do when a single measurement value changes
const N = 12;
const MEASUREMENTS = Array.from({ length: N }, (_, i) => ({
  id: `s${i}`, name: `Sensor ${String(i + 1).padStart(2, "0")}`, value: 10 + i,
}));
const CHANGED = 6, NEW_VALUE = 17.5;
const rowText = (m) => `${m.name}: ${m.value}`;

// --- A) Virtual tree: regenerate the description, compare against the old one ---
let produced = 0, compared = 0, writesA = 0;
const node = (name, children) => { produced++; return { name, children }; };
const text = (s) => { produced++; return s; };
const view = (measurements) =>
  node("ul", measurements.map((m) => node("li", [text(rowText(m))])));

function reconcile(old, next, tree) {
  compared++;
  if (typeof next === "string") {
    if (old !== next) { tree.texts[tree.k] = next; writesA++; }
    tree.k++;
    return;
  }
  old.children.forEach((c, i) => reconcile(c, next.children[i], tree));
}

const treeA = { texts: MEASUREMENTS.map(rowText), k: 0 };
const oldDescription = view(MEASUREMENTS);
produced = 0;                                    // the initial render is not counted
const nextMeasurements = MEASUREMENTS.map((m, i) =>
  i === CHANGED ? { ...m, value: NEW_VALUE } : m);
reconcile(oldDescription, view(nextMeasurements), treeA);

// --- B) Fine-grained reactivity: a binding subscribes to the cell it reads ---
let subscriptionsB = 0, writesB = 0;
const treeB = { texts: MEASUREMENTS.map(rowText) };
const cells = MEASUREMENTS.map((m, i) => {
  const cell = { value: m.value, subscribers: [] };
  cell.subscribers.push((v) => { treeB.texts[i] = `${m.name}: ${v}`; writesB++; });
  subscriptionsB++;                               // one subscription recorded at setup
  return cell;
});
const writeCell = (i, v) => {
  cells[i].value = v;
  for (const subscriber of cells[i].subscribers) subscriber(v);
};
writeCell(CHANGED, NEW_VALUE);

// --- C) Compile-time reactivity: the mapping comes from the compiler ---
// The compiler reads the template: row text depends only on the "value" field.
let writesC = 0;
const treeC = { texts: MEASUREMENTS.map(rowText) };
const COMPILED = {
  value: (i, v) => { treeC.texts[i] = `${MEASUREMENTS[i].name}: ${v}`; writesC++; },
};
COMPILED.value(CHANGED, NEW_VALUE);

// --- Result ------------------------------------------------------------------
const expected = nextMeasurements.map(rowText);
const same = (texts) => JSON.stringify(texts) === JSON.stringify(expected);
console.log(`one measurement value changed in a ${N}-row table\n`);
console.log("approach".padEnd(26) + "setup    produced  compared       writes  result");
console.log(`${"virtual tree".padEnd(26)}${String(0).padEnd(9)}${String(produced).padEnd(8)}` +
  `${String(compared).padEnd(15)}${String(writesA).padEnd(7)}${same(treeA.texts)}`);
console.log(`${"fine-grained reactivity".padEnd(26)}${String(subscriptionsB).padEnd(9)}${String(0).padEnd(8)}` +
  `${String(0).padEnd(15)}${String(writesB).padEnd(7)}${same(treeB.texts)}`);
console.log(`${"compile-time".padEnd(26)}${String(0).padEnd(9)}${String(0).padEnd(8)}` +
  `${String(0).padEnd(15)}${String(writesC).padEnd(7)}${same(treeC.texts)}`);
```

```
one measurement value changed in a 12-row table

approach                  setup    produced  compared       writes  result
virtual tree              0        25      25             1      true
fine-grained reactivity   12       0       0              1      true
compile-time              0        0       0              1      true
```

The three approaches produce the same result; the work they do differs.

**The approach that keeps a virtual tree** produces and walks the entire description
on every update: twenty-five node productions, twenty-five comparisons, one write. In
return, it records nothing and places no restriction on the view function —
conditionals, loops, helper functions, all of it is ordinary code.

**The approach that does fine-grained dependency tracking** records at setup which
binding reads which value. On update, it produces no description and does no
comparison; it runs the relevant binding directly. Its cost is the record kept for
every binding and the requirement that reads be trackable: values are accessed not
directly, but through a tracked wrapper.

**The approach that reactivates at compile time** builds the same mapping not at
runtime but in the compiler. Which part of the template reads which field is known at
compile time; the output is code that writes that field directly. Neither a record is
kept nor a comparison is done at runtime. Its cost is that a compilation step becomes
mandatory and the template language has to stay restricted enough to remain
analyzable: dynamic structures the compiler cannot see fall outside this mapping.

One more trade-off does not show up in the table. In the virtual-tree approach,
update cost is proportional to **the re-render area**; in the other two, it is
proportional to **the number of changed values**. In a small interface, the
difference cannot be measured; it becomes clear in a dashboard where fifteen badges
update several times a second. This number alone does not determine the choice — the
course's final topic covers the full set of criteria.

#### Summary

- Reconciliation compares the new view description against the stored previous one
  and produces a patch list; the stored description is called the virtual tree, and it
  prevents reads from the document tree.
- Finding the smallest transformation between two ordered trees is of cubic order;
  three assumptions — discarding the subtree on a type change, comparing level by
  level, matching siblings by key — reduce the problem to linear.
- The assumptions have a cost: changing a wrapper's type rebuilds the subtree and its
  state; a node that changes level is not moved, it is removed and re-created.
- Comparison is cheap, writing is expensive; no patch is produced for fields that do
  not change.
- Reconciliation's cost depends on the size of the produced description; the way to
  reduce it is to produce a smaller description, that is, to narrow the re-render
  area.
- Fine-grained dependency tracking solves the same problem with a record kept at
  setup, and the compile-time approach with a mapping built in the compiler; the first
  requires trackable access, the second a restricted template language and a
  compilation step.

#### Next Step

This topic built how the interface is produced from state and how what is produced is
reflected onto the tree: the view is a function, the component is a contractual piece
of that function, the template binds markup to data, the key preserves identity,
reconciliation turns the difference into a patch. One thing remains unresolved: state
itself. Reconciliation always runs after a re-render — so what triggers the
re-render? When an event handler updates two values back to back, does it render
twice, or are the updates batched together? Where exactly does a component's local
state live, and what does the call that updates it return when read on the very next
line? The next topic, Component Lifecycle and State, begins with these questions in
the Local State lesson.

## Component Lifecycle and State

The values that live across a component's renders, batching updates, registering and cleaning up side effects, memoizing derived values, mutable references, providing values through context, error boundaries, suspension, and portals out of the tree.

### Local State

Source: https://academia.sh/en/courses/component-based-development/local-state

The value that lives across a component's renders; binding state slots to call order, queuing an update, batching the updates within a single round, the stale value trap, and where in the tree state belongs.

The previous lesson worked out how the tree gets updated: the trees produced by two
renders are compared, and the difference between them is applied to the document with the
smallest possible touches. Once reconciliation finishes, one question remains. The
comparison always happens after a re-render — so what starts a re-render?

The answer is a value the component holds within itself. On the North Slope Measurement
Station page, the filter panel knows which measurement type is selected, the measurement
table knows which column it is sorted by, and each measurement badge knows whether it is
expanded. These values do not come from the server, are not passed down from a parent
component, and live until the page reloads. This lesson calls them **local state**; its
subject is where state is stored, when an update becomes visible, and how many renders
three updates within a single round produce.

#### Where State Lives

A component's body is a function that runs from scratch on every render. Local variables
defined inside the function body are reset on every call; the second render carries
nothing over from the first. State has to live somewhere outside this body.

That place is the component's **instance**. Every component position in the tree keeps a
matching instance record, and that record holds a list of slots. While the body runs, the
runtime keeps a cursor: each time the body requests state, the cursor advances by one and
hands back the slot at that position. If no slot exists yet on the first render, one opens
with the initial value; on later renders, the existing slot is read.

The function the body calls to reach this slot is called a **state hook**. The hook
returns two things: the slot's current value and a function that sends a write request to
that slot. The value is read, the write request is invoked; there is no direct assignment
between the two.

#### An Update Is a Request, Not an Assignment

The name of the write function can be misleading. It does not write to the slot the
moment it is called; it places an update request in a queue and marks that component
dirty. The queue is flushed after the running code finishes. The model below makes this
queue, its flushing, and how many renders the flush produces, visible.

```js
// state-queue.mjs — local state, the update queue, and batching
let activeInstance = null;
let renderCount = 0;
const queue = [];

function stateHook(initial) {
  const inst = activeInstance;
  const i = inst.cursor++;
  if (inst.slots.length === i) inst.slots.push(typeof initial === "function" ? initial() : initial);
  return [inst.slots[i], (next) => { queue.push({ inst, i, next }); }];
}

function render(instance) {
  activeInstance = instance;
  instance.cursor = 0;
  const output = instance.body();
  activeInstance = null;
  renderCount++;
  console.log(`  render ${renderCount}: ${output}`);
}

// Runs when the event handler finishes: the queue is flushed, each instance renders once.
function endRound() {
  const dirty = [];
  for (const job of queue) {
    const previous = job.inst.slots[job.i];
    job.inst.slots[job.i] = typeof job.next === "function" ? job.next(previous) : job.next;
    if (!dirty.includes(job.inst)) dirty.push(job.inst);
  }
  console.log(`  [end of round] queue length ${queue.length}, dirty instances: ${dirty.length}`);
  queue.length = 0;
  for (const inst of dirty) render(inst);
}

const badge = { name: "badge", slots: [], cursor: 0, external: null };
badge.body = () => {
  const [value, setValue] = stateHook(0);
  const [unit, setUnit] = stateHook("C");
  badge.external = { value, setValue, setUnit };
  return `value=${value} unit=${unit}`;
};

console.log("A. three updates writing the value directly, within a single event");
render(badge);
badge.external.setValue(badge.external.value + 1);
badge.external.setValue(badge.external.value + 1);
badge.external.setUnit("F");
console.log(`  value in the body right after writing: ${badge.external.value}`);
endRound();

console.log("B. the same operation, with an updater function");
badge.slots = [0, "C"];
renderCount = 0;
render(badge);
badge.external.setValue((previous) => previous + 1);
badge.external.setValue((previous) => previous + 1);
badge.external.setUnit("F");
endRound();

console.log("C. the same two updates made in two separate rounds");
badge.slots = [0, "C"];
renderCount = 0;
render(badge);
badge.external.setValue((previous) => previous + 1);
endRound();
badge.external.setValue((previous) => previous + 1);
endRound();
```

```
A. three updates writing the value directly, within a single event
  render 1: value=0 unit=C
  value in the body right after writing: 0
  [end of round] queue length 3, dirty instances: 1
  render 2: value=1 unit=F
B. the same operation, with an updater function
  render 1: value=0 unit=C
  [end of round] queue length 3, dirty instances: 1
  render 2: value=2 unit=F
C. the same two updates made in two separate rounds
  render 1: value=0 unit=C
  [end of round] queue length 1, dirty instances: 1
  render 2: value=1 unit=C
  [end of round] queue length 1, dirty instances: 1
  render 3: value=2 unit=C
```

#### Batching

The first thing to read in section A is that the value in the body is still zero right
after the write calls. The body holds the value it read during that render in a closure;
the closure belongs to that render, and the write request does not change it. State is
constant for the duration of a render. This is not a limitation but a guarantee: within
the same body it is impossible for a value to read as old in one place and new in
another.

The second thing to read is the render count. Three updates entered the queue, the queue
was flushed at the end of the round, and exactly **one** render happened. Accumulating
every update within a round and settling it with a single render is called **batching**.
Without batching, three writes would produce three renders; since each render means a
reconciliation round, the cost would triple. Worse, the intermediate renders would look
inconsistent: the value updated while the unit was still old.

The boundary of batching is the round boundary. In section C, the same two updates were
made in two separate rounds and produced two renders. Updates made within a single event
handler fall into the same group; updates coming from a timer callback and a network
response are separate rounds and typically produce separate renders.

#### Stale Values and the Updater Function

The only difference between A and B is the form of the write call; the results diverge to
one and two.

In A, both calls computed `value + 1` from the zero in their own closure, and both wrote
one. The second write does not overwrite the first; it writes the same result again. This
is the **stale value** trap, and it appears in any code that writes to the same slot
twice within a single round: a counter has advanced by one after two increments.

In B, the write call was given a function instead of a value. As the queue is flushed,
this function is called with the slot's value at that moment; the first request moves it
from zero to one, the second from one to two. This yields a binding rule: **if the new
value depends on the old value, an updater function is written.** If the new value is
independent of the old one — the filter's selected type, whether the panel is open —
writing the value directly is enough.

#### The Invariance of Call Order

In the model, slots are located by cursor, not by name. This has a cost: the body must
call the state hook **in the same order and the same number of times** on every render.
If a hook call is placed inside a condition, the cursor shifts on the render where the
condition changes, and the second slot's value is read from the first slot. The failure
is silent; two unrelated values swap places.

This is why state calls are written at the top of the body, unconditionally and outside
any loop. If conditional state is genuinely needed, the condition goes around the use
that follows the hook, not around the hook itself. Designs that resolve by name do not
carry this constraint, but in exchange they must check for name collisions. The gain of
an order-based design is that it locates a slot in constant time without keeping any name
registry.

#### Where State Sits in the Tree

Which component holds a piece of state is a design decision with a directly measurable
cost. When a slot changes, that component and its entire subtree re-render; siblings and
ancestors are unaffected.

```js
// state-location.mjs — render cost of holding the same state in two different nodes
const TREE = {
  name: "page",
  children: [
    { name: "title", children: [] },
    { name: "filter-panel", children: [{ name: "filter-button", children: [] },
                                        { name: "filter-list", children: [] }] },
    { name: "measurement-table", children: [{ name: "row-1", children: [] },
                                             { name: "row-2", children: [] },
                                             { name: "row-3", children: [] }] },
  ],
};

function find(node, name) {
  if (node.name === name) return node;
  for (const c of node.children) { const s = find(c, name); if (s) return s; }
  return null;
}

// The node holding the state and its entire subtree are re-rendered.
function subtree(node, collected = []) {
  collected.push(node.name);
  for (const c of node.children) subtree(c, collected);
  return collected;
}

const total = subtree(TREE).length;
for (const owner of ["page", "filter-panel"]) {
  const rendered = subtree(find(TREE, owner));
  console.log(`state in node "${owner}":`);
  console.log(`  re-rendered ${rendered.length}/${total}: ${rendered.join(", ")}`);
}
```

```
state in node "page":
  re-rendered 9/9: page, title, filter-panel, filter-button, filter-list, measurement-table, row-1, row-2, row-3
state in node "filter-panel":
  re-rendered 3/9: filter-panel, filter-button, filter-list
```

Whether the filter panel is open concerns only the panel. If this value is held at the
page root, every open and close re-renders nine components; if it is held in the panel,
it re-renders three. Holding state in the lowest component that uses it is called
**state colocation**, and it is the default preference.

The opposite direction is also necessary. Both the filter panel and the measurement table
have to know the selected measurement type. If two copies of the same value are held in
two separate slots, they inevitably drift apart. Such a value is held in the two
components' **common ancestor** and passed down; the Component Concept lesson called this
**lifting state up**. The criterion can be written in one sentence: state sits at the
nearest common ancestor of every component that needs it — no higher, no lower.

#### The Initial Value and Lazy Initialization

The hook in the model uses the initial value only when the slot first opens; on later
renders that value is never read. This has a consequence: if the initial value comes from
an expensive computation — reading from local storage, parsing a long list — the
computation runs to no purpose on every render. If the hook also accepts a function in
place of a value, the computation runs only on the first render. This is called **lazy
initialization**.

A second consequence trips people up more often: when a value coming from a parent
component is passed as the initial value, that value later changing does not update the
slot. The slot froze on the first render. For a view that needs to track the value coming
from above, that value should not be state; it should be used directly.

#### Summary

- Local state lives in a slot on the component instance, not in the component body; the
  body reads slots by call order on every render.
- The write function does not assign; it places an update request in the queue. The value
  read stays constant for the duration of a render.
- All updates within a round are batched and produce a single render; updates in separate
  rounds produce separate renders.
- If the new value depends on the old value, an updater function is written; writing the
  value directly produces a stale value on the second update within the same round.
- State hooks are called unconditionally and outside loops, because slots are located by
  order, not by name.
- State is held at the nearest common ancestor of the components that use it; holding it
  higher produces unnecessary renders, holding it in two places produces drift.

#### Next Step

The loop this lesson builds is self-contained: state changes, the body reruns, the tree
reconciles. The body has exactly one job in this loop, and that is computing an output
from inputs. Yet part of what the measurement station page has to do falls outside this
computation: fetching measurements from the server, attaching a listener to a scroll
event, writing the selected filter to local storage, setting up a timer. None of these can
be written inside the body — the body computes an output, it does not touch the outside
world. The next lesson takes up where this work is written, when it runs, and when what
it sets up has to be torn down.

### Side Effects

Source: https://academia.sh/en/courses/component-based-development/side-effects

How a component synchronizes with the outside world; the purity of rendering, an effect running after the commit phase, comparing the dependency list, the order of the cleanup function, and race conditions in asynchronous effects.

The previous lesson established that the component body has exactly one job: computing
an output from state and inputs. Part of what the measurement station page has to do
falls outside this computation. Fetching measurements from the server, subscribing to a
measurement stream, writing the selected filter to local storage, changing the document
title, setting up a timer — none of these is an output computation. These are the points
where the program touches the outside world, and they carry the name introduced in the
Programming Fundamentals course: **side effect**.

The question this lesson asks is where this work is written. The answer is a
registration mechanism: the body does not perform the side effect, it declares the work
to be done. The runtime takes this declaration, runs it at the right moment, and — the
half of this lesson most often skipped — tears down what it set up at the right moment.

#### The Purity of Rendering

There are three separate reasons a side effect cannot be written into the body, and all
three come from the body being bound to a contract: a body called with the same inputs
must produce the same output and do nothing else.

The first is timing. The tree the body produces has not yet been written to the document;
the difference from reconciliation has not been applied yet. Code inside the body that
tries to measure or focus a node is talking to a node that does not exist.

The second is cancelability. A render can be interrupted midway; when a higher-priority
update arrives, the runtime can abandon a computation already underway and start over. A
request sent inside the body is a request whose result will never be used; a counter
incremented inside the body may end up incremented twice.

The third is duplication. The same body can run twice, either because of checks during
development or because of a server-side render pass. For a pure body this is an invisible
operation; for a body that opens a subscription, it is two subscriptions.

These three reasons collapse into one rule: **the body reads and computes; every piece of
work that touches the outside is declared through registration.**

#### The Effect Record and the Dependency List

The work the body declares is called an **effect**. A record has two parts: the function
to run and a **dependency list**. After the runtime applies the computed difference to
the document — this stage is called the **commit** phase in this course — it reviews the
records and runs the ones whose list differs from the previous render's.

The comparison is an item-by-item identity comparison, not a deep equality test. Its
consequences are taken up at the end of this lesson.

```js
// effect.mjs — dependency list, run condition, and cleanup order
let active = null;
const log = (s) => console.log("   " + s);

function registerEffect(fn, deps) {
  active.pending.push({ i: active.cursor++, fn, deps });
}

// If no list is given the effect runs after every commit; if given, items are compared one by one.
function sameDeps(a, b) {
  if (a === undefined || b === undefined) return false;
  return a.length === b.length && a.every((v, k) => Object.is(v, b[k]));
}

function render(inst, props) {
  active = inst; inst.cursor = 0; inst.pending = [];
  console.log(`render: station=${props.station}`);
  inst.body(props);
  active = null;
  for (const record of inst.pending) {         // commit phase: after the render
    const previous = inst.effects[record.i];
    if (previous && sameDeps(previous.deps, record.deps)) continue;
    if (previous && previous.cleanup) previous.cleanup();
    record.cleanup = record.fn() || null;
    inst.effects[record.i] = record;
  }
}

function unmount(inst) {
  console.log("removing from tree");
  for (const e of inst.effects) if (e && e.cleanup) e.cleanup();
  inst.effects = [];
}

const panel = { effects: [], cursor: 0, pending: [] };
panel.body = ({ station }) => {
  registerEffect(() => { log("A ran (no list)"); });
  registerEffect(() => {
    log("B ran (empty list)");
    return () => log("B cleaned up");
  }, []);
  registerEffect(() => {
    log(`C ran: subscribed to ${station} stream`);
    return () => log(`C cleaned up: unsubscribed from ${station}`);
  }, [station]);
};

render(panel, { station: "NS-01" });
render(panel, { station: "NS-01" });
render(panel, { station: "NS-02" });
unmount(panel);
```

```
render: station=NS-01
   A ran (no list)
   B ran (empty list)
   C ran: subscribed to NS-01 stream
render: station=NS-01
   A ran (no list)
render: station=NS-02
   A ran (no list)
   C cleaned up: unsubscribed from NS-01
   C ran: subscribed to NS-02 stream
removing from tree
   B cleaned up
   C cleaned up: unsubscribed from NS-02
```

The list's three forms can be read separately in the output.

**When no list is given**, the effect runs after every commit. Record A ran in three of the three renders.
This form is chosen when the value to synchronize is unclear, and it is expensive.

**When an empty list** is given, the effect runs only after the first render. Record B
ran once and was cleaned up only when removed from the tree. Work that should be set up
once for the component's lifetime — setting up an observer, attaching a listener to the
document — uses this form.

**When a list with values** is given, the comparison result is decisive. Record C did
not run at all on the second render because the station had not changed; it ran on the
third because it had.

#### Cleanup

The second half of a side effect is tearing down what it set up. If the effect function
returns a function, that function is stored as the **cleanup function** and called at two
moments: right before the effect reruns, and when the component is removed from the tree.

This is why the order in the third render matters. The old subscription was cut first,
then the new one was opened. The reverse order would mean the two subscriptions coexist
for a moment; for a server that counts subscribers, this is a measurable difference.
Skipping cleanup accumulates one subscription every time the station changes. This
accumulation produces two costs: connections that never close, and — the leak defined in
the Asynchronous JavaScript and the Runtime course — callbacks that hold access to a
component no longer in the tree.

Both cleanups ran on removal from the tree. This defines cleanup's contract:
**everything an effect sets up must have a matching teardown in cleanup.** An effect that
attaches a listener removes the listener, an effect that sets up a timer cancels the
timer, an effect that subscribes cuts the subscription. Cleanup itself is written to be
idempotent, like the cleanup hooks in the Shell Programming course; closing an
already-closed connection a second time must not produce an error.

#### Race Conditions in Asynchronous Effects

The least understood use of cleanup is with network requests. Even when a request cannot
be canceled, using its result can be prevented.

```js
// race.mjs — response order breaking in an asynchronous side effect
function channel() {
  const pending = new Map();
  return {
    request: (name) => new Promise((resolve) => pending.set(name, resolve)),
    respond: (name) => pending.get(name)(`${name} measurements`),
  };
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));

async function scenario(guarded) {
  console.log(guarded ? "with cleanup:" : "without cleanup:");
  const net = channel();
  let view = "-";
  let cleanup = null;

  // The effect that runs when the dependency list [station] changes.
  const effect = (station) => {
    if (cleanup) cleanup();                 // cleans up the previous effect
    let canceled = false;
    net.request(station).then((data) => {
      if (guarded && canceled) { console.log(`  ${data} arrived, discarded`); return; }
      view = data;
      console.log(`  view <- ${data}`);
    });
    cleanup = () => { canceled = true; };
  };

  effect("NS-01");             // user selects NS-01 first
  effect("NS-02");             // immediately selects NS-02 next
  net.respond("NS-02");        // the nearby station responded first
  await tick();
  net.respond("NS-01");        // the distant station responded later
  await tick();
  console.log(`  selected station NS-02, on screen: ${view}`);
}

await scenario(false);
await scenario(true);
```

```
without cleanup:
  view <- NS-02 measurements
  view <- NS-01 measurements
  selected station NS-02, on screen: NS-01 measurements
with cleanup:
  view <- NS-02 measurements
  NS-01 measurements arrived, discarded
  selected station NS-02, on screen: NS-02 measurements
```

The user selected two stations in quick succession, and the second request was answered
before the first. This is a **race condition**: the result ends up depending on the order
responses return in, not the order requests were sent in. Without cleanup, the
measurements of the unselected station stay on screen, and no error message appears.

The fix is for each effect run to carry its own validity flag. Cleanup drops the flag; a
late-arriving response checks the flag and discards itself. The portable form of the same
pattern is built with the abort signal from the Asynchronous JavaScript and the Runtime
course: cleanup cancels the request, and the late response is never produced at all.

#### Three Common Mistakes

**A missing dependency.** A value not written into the list freezes in the closure of the
render where the effect first ran. Since the effect never reruns, that value is never
updated; this is a **stale closure**. The rule is single: every variable value read in
the effect's body goes into the list. Shortening the list to reduce how often the effect
runs does nothing but hide the bug.

**A dependency rebuilt on every render.** Because the comparison is an identity
comparison, an object or array rebuilt inside the body looks different on every render,
and the effect runs every time. This arrives at the same result as never giving a list at
all.

**Deriving state with a side effect.** An effect that computes one value from another and
writes the result into state produces two renders from a single user action, and shows an
inconsistent frame in between. This mistake is common enough that the entire next lesson
is devoted to it.

#### Summary

- The component body is pure: it reads and computes; work that touches the outside is not
  done in the body, it is declared through registration.
- An effect record runs after the commit phase; the dependency list is put through an
  item-by-item identity comparison against the previous render's list.
- With no list, the effect runs after every commit; with an empty list, only once; with a
  list of values, only when the values change.
- The cleanup function is called before the effect reruns and when the component is
  removed from the tree; everything an effect sets up must have a matching teardown in
  cleanup.
- In asynchronous effects, response order can differ from request order; a validity flag
  that cleanup drops is the smallest way to discard a late-arriving response.
- A missing dependency produces a stale closure; an object rebuilt on every render
  produces an effect that keeps running.

#### Next Step

The effect record exists for synchronizing with the outside world; yet the place it is
set up most often is not the outside world. Code that filters the measurement list when
the selected type changes in the filter panel, writes the result into state, and thereby
triggers a second render, is a common pattern — and it is wrong. The filtered list is not
state; it is a result computable from two pieces of state, and holding it in a separate
slot creates a second source of truth. The next lesson draws this distinction: which
values are stored, which are computed on every render, and, when the computation is
expensive, what the measurable payoff of storing the result is.

### Derived Values

Source: https://academia.sh/en/courses/component-based-development/derived-values

Not holding in state a result computable from other values; the single source of truth criterion, the costs of writing a derivation into state, memoization's effect on recomputation count, the difference referential stability produces downstream, and memoization's own cost.

The previous lesson established the effect record as a tool for synchronizing with the
outside world, and its last sentence left a warning: the place it is set up most often is
not the outside world. Code that filters the measurement list when the selected type
changes in the filter panel, writes the result into a separate slot, and thereby triggers
a second render, is common. This lesson establishes why that pattern is wrong, what
replaces it, and what to do when the computation is genuinely expensive.

The distinction comes down to a single question: can this value be computed from other
values I already have? If it can, it is not state; it is a **derived value**.

#### The Single Source of Truth

Five values circulate on the measurement station page: the measurement list from the
server, the type selected in the filter panel, the column the table is sorted by, the
filtered measurement list, and the count under the table that reads "17 of 42
measurements shown."

The first three are state; none can be computed from the others. The last two derive
entirely from the first three: the filtered list is a function of the measurements and
the selected type, and the count is the filtered list's length. Holding these two values
in separate slots amounts to storing the same information in two places.

Holding the same information in exactly one place is called the **single source of
truth**. The criterion is plain: before storing a value, ask whether it could instead be
computed. If it can be computed, it is not stored.

This criterion has a hidden consequence. When the filtered list is not held in state, it
is **impossible to forget** to update the filtered list when a new row is added to the
measurement list; the filtered list does not exist ahead of time at all, it is recomputed
on every render. In a design that stores the derived value, the responsibility for
updating it shifts to the programmer, and every path that gets forgotten produces an
inconsistency.

#### Three Costs of Writing a Derivation into State

The pattern of computing a derived value with a side effect and writing it into state
pays three separate costs.

**An extra render.** The sequence is: the user changes the type, state updates, the body
runs, the tree is written to the document, the effect runs, it writes the filtered list
into state, the body reruns, the tree is written again. A single click produces two full
rounds.

**An inconsistent intermediate frame.** Between these two rounds there is a frame on
screen where the selected type is new but the list is still old. A user who selected
"temperature" sees humidity measurements for a moment. This frame is short enough to go
unnoticed; on a slow device, it does not.

**Drift.** The measurement list can also change through another path — new data arrives
from the server, a row is deleted. If the effect's dependency list does not cover this
path, the filtered list stays stale, and the two sources drift apart permanently.

The correct form is to put the computation inside the body. The body recomputes the
filtered list on every render; the separate slot, the extra render, the intermediate
frame, and the drift all disappear at once. The only remaining question is the cost of
the computation.

#### Memoization

Filtering and sorting are too cheap to measure for a few hundred rows. With tens of
thousands of rows, and a date parse or a unit conversion per row, the computation becomes
visible. At that point, the target is not the computation itself but its **unnecessary
repetition**.

Storing and returning a derived value's previous result for as long as its inputs have
not changed is called **memoization**. This is the same technique introduced under the
name "note-taking" in the Programming Fundamentals course to speed up recursive
computations; the difference here is that the stored input is a dependency list.

```js
// memoization.mjs — memoizing a derived value and the recomputation count
const MEASUREMENTS = [
  { code: "NS-01", type: "temperature", value: -4.2 },
  { code: "NS-02", type: "humidity", value: 68 },
  { code: "NS-03", type: "temperature", value: 1.5 },
  { code: "NS-04", type: "wind", value: 12.4 },
  { code: "NS-05", type: "temperature", value: -0.8 },
];

let computeCount = 0;
function filterAndSort(measurements, type) {
  computeCount++;
  return measurements
    .filter((m) => type === "all" || m.type === type)
    .sort((a, b) => a.value - b.value)
    .map((m) => m.code);
}

function memoized(slots, i, compute, deps) {
  const slot = slots[i];
  const same = slot && slot.deps.length === deps.length
    && slot.deps.every((v, k) => Object.is(v, deps[k]));
  if (same) return slot.result;
  const result = compute();
  slots[i] = { deps, result };
  return result;
}

// There are also renders that do not change the type: opening and closing the panel changes state.
const TYPES = [
  ["all", false], ["all", true], ["all", false],
  ["temperature", false], ["temperature", true], ["humidity", true],
];

for (const memoize of [false, true]) {
  computeCount = 0;
  const slots = [];
  console.log(memoize ? "memoized:" : "computed on every render:");
  for (const [type, open] of TYPES) {
    const list = memoize
      ? memoized(slots, 0, () => filterAndSort(MEASUREMENTS, type), [MEASUREMENTS, type])
      : filterAndSort(MEASUREMENTS, type);
    console.log(`  type=${type.padEnd(11)} panel=${String(open).padEnd(5)} list=${list.join(",")}`);
  }
  console.log(`  6 renders, ${computeCount} computations`);
}
```

```
computed on every render:
  type=all         panel=false list=NS-01,NS-05,NS-03,NS-04,NS-02
  type=all         panel=true  list=NS-01,NS-05,NS-03,NS-04,NS-02
  type=all         panel=false list=NS-01,NS-05,NS-03,NS-04,NS-02
  type=temperature panel=false list=NS-01,NS-05,NS-03
  type=temperature panel=true  list=NS-01,NS-05,NS-03
  type=humidity    panel=true  list=NS-02
  6 renders, 6 computations
memoized:
  type=all         panel=false list=NS-01,NS-05,NS-03,NS-04,NS-02
  type=all         panel=true  list=NS-01,NS-05,NS-03,NS-04,NS-02
  type=all         panel=false list=NS-01,NS-05,NS-03,NS-04,NS-02
  type=temperature panel=false list=NS-01,NS-05,NS-03
  type=temperature panel=true  list=NS-01,NS-05,NS-03
  type=humidity    panel=true  list=NS-02
  6 renders, 3 computations
```

The two sections' **lists are identical line by line**; memoization does not change the
result, only the number of times it gets produced. In three of the six renders, the type
is the same as the previous one — opening and closing the panel produces a render but
does not concern the filter — and the memoized form returned the stored result on those
three occasions.

The memoization slot holds a single input. If "all" were selected again after the last
round, the computation would run from scratch, because the intervening rounds had already
filled the slot. This is not a shortcoming but a deliberate limit: an unbounded cache
holds memory for every distinct input combination and requires an eviction policy. A
single-step window carried from one render to the next already covers typical usage.

#### Referential Stability

Memoization's second justification comes not from speed but from identity, and it is
less well known. If the derived value is an array or an object, the result rebuilt on
every render has **the same content but a different identity**. This difference
propagates downward: the dependency list of the component receiving the result looks
changed on every render.

```js
// identity.mjs — the downstream effect of a derived value's identity
const MEASUREMENTS = [
  { code: "NS-01", type: "temperature" },
  { code: "NS-02", type: "humidity" },
  { code: "NS-03", type: "temperature" },
];

const temperatureReadings = (source) => source.filter((m) => m.type === "temperature");

function memoized(slots, i, compute, deps) {
  const slot = slots[i];
  if (slot && slot.deps.length === deps.length && slot.deps.every((v, k) => Object.is(v, deps[k]))) return slot.result;
  const result = compute();
  slots[i] = { deps, result };
  return result;
}

for (const memoize of [false, true]) {
  const slots = [];
  let previousList = null;   // the value the child component keeps in its dependency list
  let effectCount = 0;
  console.log(memoize ? "memoized derivation:" : "re-derived on every render:");

  for (let n = 1; n <= 4; n++) {          // four renders, inputs never change
    const list = memoize
      ? memoized(slots, 0, () => temperatureReadings(MEASUREMENTS), [MEASUREMENTS])
      : temperatureReadings(MEASUREMENTS);
    const sameObject = Object.is(list, previousList);
    const deepEqual = previousList !== null
      && list.length === previousList.length
      && list.every((m, k) => m === previousList[k]);
    if (!sameObject) effectCount++;         // the child's effect reruns
    console.log(
      `  render ${n}: same object=${String(sameObject).padEnd(5)}`,
      `same content=${previousList === null ? "-    " : String(deepEqual).padEnd(5)}`,
      `child effect ran=${!sameObject}`,
    );
    previousList = list;
  }
  console.log(`  4 renders, child effect ran ${effectCount} time(s)`);
}
```

```
re-derived on every render:
  render 1: same object=false same content=-     child effect ran=true
  render 2: same object=false same content=true  child effect ran=true
  render 3: same object=false same content=true  child effect ran=true
  render 4: same object=false same content=true  child effect ran=true
  4 renders, child effect ran 4 time(s)
memoized derivation:
  render 1: same object=false same content=-     child effect ran=true
  render 2: same object=true  same content=true  child effect ran=false
  render 3: same object=true  same content=true  child effect ran=false
  render 4: same object=true  same content=true  child effect ran=false
  4 renders, child effect ran 1 time(s)
```

The input did not change in any of the four renders. In the unmemoized form, the "same
content" column reads true on every line while the "same object" column reads false on
every line: the list is an equivalent but different array every time. The effect in the
component below counts this as a change and runs four times. In the memoized form it runs
once.

This is called **referential stability**, and it is the real reason for memoization
whenever a derived value crosses a component boundary. Making the comparison an identity
comparison is a design choice: a deep comparison costs more than the computation itself
on large structures. Its price is that preserving identity becomes the caller's
responsibility.

The same reasoning applies to functions. A callback written inside the body is a new
function object on every render; when passed down, it looks changed every time.
Memoizing the function solves the same problem as memoizing the array.

#### Memoization's Own Cost

Memoization is not free. Every memoized value holds a slot, a list comparison runs on
every render, and the stored result stays in memory. For a filtered array of three
items, this cost is larger than the computation itself.

Three criteria are enough to decide. If the computation is genuinely expensive —
measured, not guessed — it is memoized. If the result crosses a component boundary and
feeds into a dependency list below, it is memoized. If neither holds, it is not memoized.

Memoizing without measuring produces a body that is hard to read and a gain that cannot
be measured. The performance recording from The Browser and the Web Platform course is
the only source that tells which computation is genuinely expensive; dependency lists are
added by consulting that recording.

#### Summary

- A value computable from other values is not state; it is not stored, it is derived on
  every render.
- Writing a derivation into state with a side effect produces an extra render, an
  inconsistent intermediate frame, and the risk that the sources drift apart.
- Memoization returns the previous result for as long as the dependency list has not
  changed; it does not change the result, it reduces the recomputation count.
- The memoization slot holds a single-step window; returning to an old input makes the
  computation run again.
- Referential stability prevents dependency lists downstream from appearing to change
  needlessly when a derived array or function crosses a component boundary.
- Memoization carries the cost of a slot, a comparison, and memory; it is not added for
  an unmeasured computation.

#### Next Step

Derived values are recomputed on every render, while state lives across renders and
starts a new render when it changes. There is a gap between these two categories. A
timer's identity, a request's sequence number, an element's previous position, the very
node that scrolls the table — all of them need to live across renders, yet none of their
changes produces a change on screen. Writing them into state triggers an unnecessary
render on every change; holding them in a local variable means losing them on the second
render. The next lesson defines this third category.

### References

Source: https://academia.sh/en/courses/component-based-development/references

Values that live across renders but do not start a re-render when they change; the two axes of storage forms, the box filling during the commit phase, the rule against touching the box during a render, and passing access to a document node across a component boundary.

The previous two lessons established two categories. State lives across renders and
starts a new render when it changes. A derived value is never stored; it is recomputed on
every render. On the measurement station page there are values that fall into neither
category.

A counter that counts scroll events, the sequence number of a sent request, a timer's
identity, the filter type from the previous render, the document node that holds the
table itself. What they all have in common is this: they need to live across renders,
but their changing does not change anything on screen. This lesson defines the third
category and calls it a **mutable reference**. For short, it will be called a box: a
container tied to the component instance that holds a single field.

#### Two Axes

Deciding where to hold a value comes down to two independent questions: does the value
survive into the next render, and does changing it start a render?

| Storage form | Survives across renders | Changing it starts a render |
|---|---|---|
| Local variable inside the body | no | no |
| State slot | yes | yes |
| Mutable reference | yes | no |
| Module-level variable | yes | no |

Three of the four rows serve distinct needs. A local variable is for holding an
intermediate result within a single render. A state slot is for everything visible on
screen. A reference is for values that need to live but stay invisible.

The fourth row — the module-level variable — gives the same two answers as the
reference, but with a difference: it is tied to the module, not the component. If a page
has two instances of the same component, both share the same variable. When a page has
two measurement tables, holding the scroll counter at module level mixes the two tables'
counters together. A reference is tied to the instance; each table gets its own box.

#### Holding the Same Counter in Three Different Places

The difference is measurable. The measurement table counts scroll events but updates the
on-screen indicator only every fifth event. The same requirement is met with three
storage forms.

```js
// storage.mjs — the result of holding the same counter in three different places
const EVENTS = ["s", "s", "s", "o", "s", "s", "s", "s", "s", "o", "s", "s"];
const THRESHOLD = 5;

function scenario(form) {
  let renderCount = 1;           // the first render
  let local = 0;                 // local variable inside the body
  let state = 0;                 // state slot
  const box = { value: 0 };      // mutable reference
  let onScreen = 0;

  const render = () => { renderCount++; local = 0; };   // local resets on every render

  for (const event of EVENTS) {
    if (event === "o") { render(); continue; }          // a state change unrelated to the counter
    if (form === "local") {
      local++;
      if (local % THRESHOLD === 0) { onScreen = local; render(); }
    } else if (form === "state") {
      state++;
      onScreen = state;
      render();                                         // every write produces a render
    } else {
      box.value++;
      if (box.value % THRESHOLD === 0) { onScreen = box.value; render(); }
    }
  }

  const counter = form === "local" ? local : form === "state" ? state : box.value;
  console.log(
    `${form.padEnd(5)} | counter=${String(counter).padStart(2)}`,
    `| onScreen=${String(onScreen).padStart(2)} | render=${renderCount}`,
  );
}

console.log("10 scrolls + 2 unrelated state changes; the screen updates every 5th scroll");
for (const f of ["local", "state", "box"]) scenario(f);
```

```
10 scrolls + 2 unrelated state changes; the screen updates every 5th scroll
local | counter= 2 | onScreen= 5 | render=4
state | counter=10 | onScreen=10 | render=13
box   | counter=10 | onScreen=10 | render=5
```

The three rows show three different results.

The local variable **loses** the count. Ten scrolls have been counted, yet the counter stops at two,
because every intervening render — the one render the counter itself triggered and the two coming from the
filter panel — reset the variable. This is not a shortcoming of the local variable but its definition.

The state slot counts correctly, but produces thirteen renders. Every increment of the
counter means a render; yet the screen changed in only two of those increments. Eleven
renders' output is identical to the one before it.

The reference box counts correctly and produces five renders: the two renders where the
screen genuinely changed, the two coming from the filter panel, and the first render.
This states in a single line why the third category exists.

#### When the Box Fills

The second use of a reference is reaching the document node a component produces.
Focusing a node, measuring it, or handing it to an observer requires the node itself; yet
the component body produces not nodes but a description of nodes. The link is established
by the runtime filling the box.

```js
// when-the-box-fills.mjs — when the node box fills, and the "previous value" pattern
const box = (initial) => ({ value: initial });

const nodeBox = box(null);        // the table node in the document will be written here
const previousType = box(null);   // the type from the previous render

let renderCount = 0;

function body(type) {
  renderCount++;
  const changed = previousType.value !== null && previousType.value !== type;
  console.log(`render ${renderCount} (type=${type})`);
  console.log(`   body reads -> node box     : ${nodeBox.value}`);
  console.log(`   body reads -> previous type: ${previousType.value}, changed: ${changed}`);
  return { tag: "table", key: `table-${type}` };
}

function commitToDocument(tree) {
  const node = `[node ${tree.tag} ${tree.key}]`;
  nodeBox.value = node;           // the runtime fills the box
  console.log(`   commit phase -> box = ${node}`);
}

function effects(type) {
  console.log(`   effect reads -> ${nodeBox.value} can be measured`);
  previousType.value = type;      // stored for the next render
}

for (const type of ["temperature", "temperature", "humidity"]) {
  const tree = body(type);
  commitToDocument(tree);
  effects(type);
}
```

```
render 1 (type=temperature)
   body reads -> node box     : null
   body reads -> previous type: null, changed: false
   commit phase -> box = [node table table-temperature]
   effect reads -> [node table table-temperature] can be measured
render 2 (type=temperature)
   body reads -> node box     : [node table table-temperature]
   body reads -> previous type: temperature, changed: false
   commit phase -> box = [node table table-temperature]
   effect reads -> [node table table-temperature] can be measured
render 3 (type=humidity)
   body reads -> node box     : [node table table-temperature]
   body reads -> previous type: temperature, changed: true
   commit phase -> box = [node table table-humidity]
   effect reads -> [node table table-humidity] can be measured
```

The sequence has three stages: the body runs, the tree is written to the document, the
effects run. The box fills in the second stage. This is why the box is empty in the first
render's body — the node to be measured does not exist yet — and full in the effect.
Code that measures a node, focuses it, or hands it to an observer is therefore written in
an effect record.

The third render's body shows a second detail: at that moment, the box is carrying the
node from **the previous render**. While the body runs, the old tree still sits in the
document. This is not staleness in the box but the order of the stages.

The second box establishes a different pattern. A box written in an effect and read in
the body carries the value from the previous render and makes comparison possible. In the
third render, "changed" came out true: the body knows the type changed in this round only
because of the box. This is called the **previous value pattern**.

#### Not Touching the Box During a Render

The binding rule: the body neither reads nor writes a box. Touching a box is free in
effects, event handlers, and timer callbacks.

The reasoning has three parts. The body's contract is purity, and writing to a box is a
side effect. The body can be interrupted midway; a value an interrupted body wrote to a
box is left over from a render that is never shown. While the body runs, the document
still carries the previous tree; a node box that is read gives back the old node.

The rule's only exception is setting a box's initial value once: filling it when empty
and leaving it untouched once full does not break purity, because it produces no
different result on any render.

#### Access to a Document Node and the Component Boundary

The legitimate uses of holding a reference to a node are countable: moving focus to an
element, reading an element's measurements, attaching an observer to a target, playing a
media element, handing a container to a library written outside the component's control.
Everything outside this list — adding a class, writing text, hiding a child — is done
declaratively, that is, from state. Code that mutates the node by hand produces a change
that reconciliation overwrites on the next render, and the two update sources collide.

A reference crossing a component boundary is a separate design decision. A parent
component may want access to the node a child produces: when the filter panel closes,
focus needs to return to the first row in the table. Exposing the raw node breaks
encapsulation; code outside the component becomes bound to its internal structure — which
tag is used, how many wrappers there are.

A more robust form exposes a command surface, not the node: the component hands out named
operations like `focus` and `scrollToTop`, and keeps the node to itself. When the internal
structure changes, only the component changes. This is the interface–implementation
distinction from the Programming Fundamentals course, applied to components.

#### When State, When a Box

The decision is made with a single question: **when this value changes, should anything
change on screen?**

If it should, that value is state; writing it to a box silently leaves the screen stale
and produces a class of bug that is hard to find — the data is correct, the view is
stale. If it should not, it is a box; writing it to state produces an unnecessary render
on every change.

Two in-between cases come up often. If a value concerns the screen only some of the
time, it is split in two: the raw counter in a box, the threshold value shown on screen
in state — as in the example above. If a value is read only in an event handler, it is a
box; no render reads it.

#### Summary

- Storage forms split along two axes: surviving across renders, and starting a render
  when they change.
- A mutable reference survives but does not start a render; its difference from a
  module-level variable is that it is tied to the component instance.
- The same counter is lost when held in a local variable, produces unnecessary renders
  when held in state, and produces neither problem when held in a box.
- The box fills during the commit phase; while the body runs, it carries the previous
  render's node, which is why code that touches the node is written in an effect.
- The body neither reads nor writes a box; the only exception is setting the box's
  initial value once.
- A node reference is for focus, measurement, and binding to an external library; what
  crosses the boundary is named commands, not the raw node.

#### Next Step

All three storage forms defined so far stay inside a single component. There is exactly
one way for a value to reach another component: passed down from parent to child. On the
measurement station page there is a case where this path grows long. Whether the
temperature unit shows as Celsius or Fahrenheit is chosen at the very top of the page, but
the place that needs this information is at the very bottom, in the measurement badge
inside the table. None of the four components in between uses the unit; they only carry
it. The next lesson takes up the mechanism that shortens this chain: providing a value in
the tree and reading it at any node below, without touching the components in between.

### Context Passing

Source: https://academia.sh/en/courses/component-based-development/context-passing

Providing a value in the tree and reading it at any node below; the problem with prop drilling, the provider resolving upward, the nearest provider shadowing, the effect of the provided value's identity on consumer count, and where context does not fit.

All three storage forms from the previous lesson stay inside a single component. There is
one known way for a value to reach another component: passed down from parent to child
as a prop. On the measurement station page this path grows long. Whether the temperature
shows as Celsius or Fahrenheit is chosen at the very top of the page; the place that
needs this information is at the very bottom, in the measurement badge inside the table
row. The four components in between — the page body, the measurement table, the table
row, the cell — never use the unit, they only carry it.

This lesson establishes the mechanism that shortens the chain: **providing** the value at
one node in the tree and **reading** it at any node below, without touching anything in
between.

#### The Problem with Prop Drilling

Carrying a value through intermediate components is called **prop drilling**. The chain
itself is not a mistake; for two or three levels, it is the most obvious solution. As it
grows longer, it produces three separate costs.

The first is interface expansion. The measurement table's API now has a unit parameter,
even though the table has no concern with the unit at all. The component's externally
visible contract fills up with fields that say nothing about its own job.

The second is the cost of change. Adding a new value to the chain — the locale, the
number of decimal places, threshold colors — means a signature change in every component
in between. Adding three values to a five-level chain is touching fifteen files.

The third is reuse. A table forced to carry the unit cannot be used on a page that has no
unit; and once the parameter is made optional, the question of where the default comes
from arises.

#### Provider and Resolution

Context removes all three costs. A node in the tree **provides** a value under a key; any
component below that node reads the value with the same key. The components in between
are never aware of it.

Reading is a search: the component starts from its own node, climbs up the ancestor
chain, and stops at the first node that provides that key. If no node provides it, the
default value given along with the context's definition is used.

```js
// context-resolution.mjs — the provider being searched for upward through the tree
const node = (name, provided, children = []) => {
  const n = { name, provided, children, parent: null };
  for (const c of children) c.parent = n;
  return n;
};

const DEFAULTS = { unit: "kelvin" };   // used when no provider exists

function resolve(start, key) {
  let n = start;
  let steps = 0;
  while (n) {
    if (n.provided && key in n.provided)
      return { value: n.provided[key], provider: n.name, steps };
    n = n.parent;
    steps++;
  }
  return { value: DEFAULTS[key], provider: "(default)", steps };
}

const badge1 = node("badge-1");
const badge2 = node("badge-2");
const badge3 = node("badge-3");
const page = node("page", { unit: "celsius" }, [
  node("title"),
  node("filter-panel", null, [node("filter-list", null, [badge3])]),
  node("measurement-table", null, [
    node("row-1", null, [badge1]),
    node("comparison-panel", { unit: "fahrenheit" }, [badge2]),
  ]),
]);
const standaloneBadge = node("standalone-badge");   // outside the tree

console.log(`root: ${page.name}`);
for (const n of [badge1, badge2, badge3, standaloneBadge]) {
  const r = resolve(n, "unit");
  console.log(
    `  ${n.name.padEnd(17)} unit=${r.value.padEnd(10)}`,
    `source=${r.provider.padEnd(16)} ${r.steps} ancestor node(s) scanned`,
  );
}
```

```
root: page
  badge-1           unit=celsius    source=page             3 ancestor node(s) scanned
  badge-2           unit=fahrenheit source=comparison-panel 1 ancestor node(s) scanned
  badge-3           unit=celsius    source=page             3 ancestor node(s) scanned
  standalone-badge  unit=kelvin     source=(default)        1 ancestor node(s) scanned
```

The four rows show four separate behaviors. The first badge found the page provider three
levels up; the table and row components in between never saw the unit.

The second badge is inside the comparison panel and stops at the provider one level up.
The page provider is still there, but it is never searched for: **the nearest provider
wins.** This makes it possible for one section of the same page to display with a
different unit.

The third badge is on an entirely different branch of the tree and finds the page
provider all the same. Context spreads through a subtree, not through a single branch.

The fourth badge is below no provider at all and falls back to the default. Choosing a
meaningful default is a design task: a component that also works without context can be
tested on its own and reused on another page.

#### Context Is Positional, Not Lexical

The most commonly misunderstood aspect of this mechanism is the point where it departs
from the thing it resembles. Scope in the Programming Fundamentals course is **lexical**:
the variables a function sees depend on where that function is written in the source
code. Context, on the other hand, is **positional**: the value a component sees depends
on where that component is placed in the tree.

The same measurement badge is written once in the source; it reads Celsius inside the
table and Fahrenheit inside the comparison panel. The source code has not changed; the
place has.

The nearest provider winning is the tree's counterpart to the **shadowing** defined in the
same course: the inner provider renders the outer one invisible within its own subtree.
This power comes with a cost. Understanding which value a component will read is not
enough from looking at its definition; its position in the tree has to be known. This is
why values carried through context should be few in number and clearly named.

#### The Cost of Change

When the provided value changes, every component reading that context re-renders —
wherever they are in the tree, even if the components in between have not changed. The
provided value's identity is therefore a direct cost item.

```js
// context-cost.mjs — the effect of the provided value's identity on consumer count
const UNIT_CONSUMERS = 6;    // number of components reading the unit
const ROW_CONSUMERS = 2;     // number of components reading the selected row

const EVENTS = [
  { name: "panel opened",   unit: "celsius",    selected: null },
  { name: "row 1 selected", unit: "celsius",    selected: 1 },
  { name: "row 2 selected", unit: "celsius",    selected: 2 },
  { name: "unit changed",   unit: "fahrenheit", selected: 2 },
];

function run(strategy) {
  let previousUnit = "celsius";     // values set up on the first render
  let previousSelected = null;
  let total = 0;
  console.log(`${strategy}:`);
  for (const event of EVENTS) {
    let count = 0;
    if (strategy === "object built in the body") {
      count = UNIT_CONSUMERS + ROW_CONSUMERS;          // identity changes on every render
    } else if (strategy === "memoized single context") {
      const changed = event.unit !== previousUnit || event.selected !== previousSelected;
      count = changed ? UNIT_CONSUMERS + ROW_CONSUMERS : 0;
    } else {
      if (event.unit !== previousUnit) count += UNIT_CONSUMERS;
      if (event.selected !== previousSelected) count += ROW_CONSUMERS;
    }
    previousUnit = event.unit;
    previousSelected = event.selected;
    total += count;
    console.log(`  ${event.name.padEnd(14)} -> consumers re-rendered: ${count}`);
  }
  console.log(`  total: ${total}`);
}

console.log(`${UNIT_CONSUMERS} unit consumers, ${ROW_CONSUMERS} row consumers, 4 events`);
for (const s of ["object built in the body", "memoized single context", "two separate contexts"])
  run(s);
```

```
6 unit consumers, 2 row consumers, 4 events
object built in the body:
  panel opened   -> consumers re-rendered: 8
  row 1 selected -> consumers re-rendered: 8
  row 2 selected -> consumers re-rendered: 8
  unit changed   -> consumers re-rendered: 8
  total: 32
memoized single context:
  panel opened   -> consumers re-rendered: 0
  row 1 selected -> consumers re-rendered: 8
  row 2 selected -> consumers re-rendered: 8
  unit changed   -> consumers re-rendered: 8
  total: 24
two separate contexts:
  panel opened   -> consumers re-rendered: 0
  row 1 selected -> consumers re-rendered: 2
  row 2 selected -> consumers re-rendered: 2
  unit changed   -> consumers re-rendered: 6
  total: 10
```

The first strategy is the common style of rebuilding the provided value inside the
provider's body on every render. Even when the value's content does not change, its
identity does, so even the panel opening re-renders eight consumers. This is context's
counterpart to the referential stability problem from the previous lesson, and its fix is
the same: the provided value is memoized.

The second strategy does this and filters out irrelevant renders; the thirty-two consumer
renders drop to twenty-four. But every time the selected row changes, the six components
reading the unit also re-render, because the two are carried in a single object.

The third strategy separates the values: the rarely-changing unit in one context, the
frequently-changing selection in another. The total drops to ten. This yields the rule:
**values carried together in one context should change at the same frequency.** If their
change frequencies diverge, the context is split.

#### Where Context Does Not Fit

Context is not a state management solution; it is a transport path. The value is still
held as state somewhere; context only lets it travel through the tree.

The values it suits share a common trait: they change rarely and concern a wide portion
of the tree. Visual theme, measurement unit, locale, signed-in user information,
formatting settings, and accessibility preferences fit this description.

Where it does not fit is just as clear. A frequently-changing value — scroll position,
the row under the cursor, an animation's progress value — re-renders every consumer on
every change when carried through context. Setting up context for a single value shared
between two components is more complex and more costly than lifting state up, from the
previous lesson.

One last criterion is testability. A component that reads context falls back to the
default value when rendered on its own. If the default is meaningful, the component can
be tested independently; if there is no default and the absence of context produces an
error, the component has to be wrapped in a provider on every test.

#### Summary

- As prop drilling grows longer, it expands intermediate components' interfaces, raises
  the cost of change, and makes reuse harder.
- Reading context is a search that climbs from the consumer node up the ancestor chain
  and stops at the first node that provides that key; if none does, the default is used.
- The nearest provider wins; the inner provider shadows the outer one within its own
  subtree.
- Context is positional, not lexical: the value a component sees depends on its position
  in the tree, not the place of its definition.
- If the provided value is rebuilt on every render, every consumer re-renders on every
  render; the value is memoized.
- Values carried together in one context should change at the same frequency; if the
  frequencies diverge, the context is split.

#### Next Step

Context flows a value down through the tree. There is one more thing that flows in the
reverse direction, and it has not been addressed up to this lesson: errors. What happens
if a row in the measurement table throws an exception during render because of a
corrupted record from the server? In a declarative tree, the answer to this question is
harsher than expected: a half-finished tree is inconsistent, and the runtime has no
information in hand to salvage it. The next lesson takes up the reasoning behind this
behavior, the mechanism that isolates a subtree's error from its surroundings, and the
kinds of errors this mechanism cannot catch.

### Error Boundaries

Source: https://academia.sh/en/courses/component-based-development/error-boundaries

Isolating an error thrown during rendering within the component tree; why a half-finished tree is not left in place, the nearest boundary catching it, the effect of boundary placement on the number of components remaining on screen, errors that fall outside the call stack being out of scope, and recovery.

The previous lesson established the mechanism that flows a value down through the tree.
There is one more thing that flows in the reverse direction, and it has not been
addressed up to this lesson: errors.

Suppose a row in the measurement table throws an exception during render because a
record from the server has no unit field. In a hand-written interface, this means that
row stays empty. In a declarative tree, the response is harsher: the default behavior is
for the entire tree to be torn down and the screen to go blank. This lesson first
establishes the reasoning behind this behavior, then shows the way to contain the error
within a subtree, and finally the kinds of errors this way does not cover.

#### Why a Half-Finished Tree Is Not Left in Place

Reconciliation compares the **entire** tree a render produces against the previous one
and applies the difference in a single commit. When an exception is thrown midway, two
things exist: a partially computed new tree and the old tree still sitting in the
document.

If the runtime were to write the partial tree at this point, it would put on screen a
state whose components' contracts are not known to hold for any of them. The table
header says "temperature measurements" according to the new filter, while the rows still
show humidity values left over from the old filter. On a measurement station page, this
is worse than a blank screen: the user mistakes wrong data for correct data.

How far the error has spread is also unknown. The component that threw may have
registered an effect, written to a box, or set up half of a subscription. Tearing down is
returning to the one known consistent state — nothing.

This is why the default is strict, and deliberately so. Softening it means telling the
runtime in advance which subtree can fail and what to show in its place.

#### The Error Boundary

A component that catches render errors in its subtree and puts a predetermined view in
their place is called an **error boundary**. The view it puts in place will be called the
**fallback** in this lesson; a separate name is used so it is not confused with the
fallback content of slots from The Browser and the Web Platform course.

The propagation rule is the same as exception propagation from the Programming
Fundamentals course: the error climbs the call chain and stops at the **nearest**
boundary that catches it.

```js
// error-boundary.mjs — a render error propagating up to the nearest boundary
const tree = (boundaries, broken) => ({
  name: "page", boundary: boundaries.includes("page"), children: [
    { name: "title", children: [] },
    { name: "filter-panel", boundary: boundaries.includes("filter-panel"), children: [
      { name: "filter-list", children: [] },
    ] },
    { name: "measurement-table", boundary: boundaries.includes("measurement-table"), children: [
      { name: "row-1", children: [] },
      { name: "row-2", error: broken ? "missing unit field" : null, children: [] },
      { name: "row-3", children: [] },
    ] },
  ],
});

// The returned count is the number of components remaining on screen: the caught subtree's work is discarded.
function render(n, log) {
  if (n.boundary) {
    try {
      const results = n.children.map((c) => render(c, log));
      return { text: `${n.name}(${results.map((x) => x.text).join(" ")})`,
               count: 1 + results.reduce((t, x) => t + x.count, 0) };
    } catch (err) {
      log.push(`${n.name} caught -> ${err.message}`);
      return { text: `${n.name}(FALLBACK)`, count: 1 };
    }
  }
  if (n.error) throw new Error(`${n.name}: ${n.error}`);
  if (n.children.length === 0) return { text: n.name, count: 1 };
  const results = n.children.map((c) => render(c, log));
  return { text: `${n.name}(${results.map((x) => x.text).join(" ")})`,
           count: 1 + results.reduce((t, x) => t + x.count, 0) };
}

const countNodes = (n) => 1 + n.children.reduce((t, c) => t + countNodes(c), 0);
const TOTAL = countNodes(tree([], false));

const ALL = ["page", "filter-panel", "measurement-table"];
const SCENARIOS = [
  ["three boundaries, broken record", ALL, true],
  ["page boundary only, broken record", ["page"], true],
  ["no boundary, broken record", [], true],
  ["three boundaries, record fixed", ALL, false],
];

for (const [title, boundaries, broken] of SCENARIOS) {
  const log = [];
  console.log(title + ":");
  let result;
  try {
    result = render(tree(boundaries, broken), log);
  } catch (err) {
    log.push(`nobody caught -> ${err.message}`);
    result = { text: "(tree torn down entirely)", count: 0 };
  }
  for (const s of log) console.log(`  ${s}`);
  console.log(`  on screen: ${result.text}`);
  console.log(`  components remaining on screen: ${result.count}/${TOTAL}`);
}
```

```
three boundaries, broken record:
  measurement-table caught -> row-2: missing unit field
  on screen: page(title filter-panel(filter-list) measurement-table(FALLBACK))
  components remaining on screen: 5/8
page boundary only, broken record:
  page caught -> row-2: missing unit field
  on screen: page(FALLBACK)
  components remaining on screen: 1/8
no boundary, broken record:
  nobody caught -> row-2: missing unit field
  on screen: (tree torn down entirely)
  components remaining on screen: 0/8
three boundaries, record fixed:
  on screen: page(title filter-panel(filter-list) measurement-table(row-1 row-2 row-3))
  components remaining on screen: 8/8
```

The same error produces three different results depending on boundary placement.

If the table has its own boundary, only the table falls back; the title and the filter
panel stay up and remain **usable**. The user can change the filter and retry with a
different query.

If there is only a page-level boundary, the error climbs two levels and the entire page
collapses to a single fallback. With no boundary at all, nothing remains on screen.

The last scenario underscores a point: a boundary does nothing when there is no error. It
carries no invisible cost; it only engages at the moment of an error.

#### Placing Boundaries

The 5, 1, and 0 in the output give the placement rule: **a boundary is placed directly
above the part that can fail, not at the very top of the page.**

A three-level placement is enough in practice. A last-resort boundary sits at the
outermost edge of the page; even if it catches nothing else, it shows a message instead
of a blank screen. Every main section of the page — the filter panel, the measurement
table, the comparison panel — carries its own boundary; while one collapses, the others
keep working. Small parts that depend on external data and can refresh independently, a
single measurement badge for instance, can carry their own boundary.

The opposite extreme is also a mistake. Wrapping every component with a boundary silently
puts a small fallback in place of the collapsed part, and the page looks "working" while
half of it is missing. A boundary is not a tool for hiding errors; it is a tool for
limiting damage.

#### What a Boundary Does Not Catch

A boundary is a call stack mechanism, and its scope is limited by that stack.

```js
// not-caught.mjs — a boundary's scope is limited by the call stack
const stack = [];
const stackState = () => `stack = [${stack.join(" > ") || "empty"}]`;

function boundary(name, body) {
  stack.push(name);
  try {
    return body();
  } catch (err) {
    console.log(`  ${name} CAUGHT -> ${err.message}`);
    return `${name}(FALLBACK)`;
  } finally {
    stack.pop();
  }
}

const pending = [];
let handler = null;

console.log("A. an error thrown during render");
const output = boundary("table-boundary", () => {
  console.log(`  body running, ${stackState()}`);
  handler = () => { throw new Error("delete button handler blew up"); };
  pending.push(["measurement response", () => { throw new Error("network response corrupted") }]);
  throw new Error("row-2: missing unit field");
});
console.log(`  on screen: ${output}`);

console.log("B. event handler: the render is long since finished");
console.log(`  ${stackState()}`);
try { handler(); } catch (err) { console.log(`  did not hit the boundary -> ${err.message}`); }

console.log("C. asynchronous callback: a separate round");
for (const [name, job] of pending) {
  console.log(`  ${name} running, ${stackState()}`);
  try { job(); } catch (err) { console.log(`  did not hit the boundary -> ${err.message}`); }
}
```

```
A. an error thrown during render
  body running, stack = [table-boundary]
  table-boundary CAUGHT -> row-2: missing unit field
  on screen: table-boundary(FALLBACK)
B. event handler: the render is long since finished
  stack = [empty]
  did not hit the boundary -> delete button handler blew up
C. asynchronous callback: a separate round
  measurement response running, stack = [empty]
  did not hit the boundary -> network response corrupted
```

The stack lines across the three sections explain the difference on their own. While the
body runs, the boundary is on the stack, and the thrown error hits it. By the time the
event handler is called and the asynchronous callback runs, the stack is empty; the
boundary's protective block has long since ended.

The practical consequence of this is that error handling is set up in two separate
places. Render errors are met by a boundary. Errors in event handlers and asynchronous
work are caught inside the code doing that work, and the result is written to state — an
error message state, a retry button. The same distinction holds for unhandled promise
rejection from the Asynchronous JavaScript and the Runtime course.

There are two more cases a boundary does not cover: if the boundary's own fallback view
throws, the error climbs to the next boundary up, and errors from a render produced
server-side never hit a boundary on the client.

#### Recovery and Reporting

Catching is not enough; the way out of a caught error is also designed.

Because the boundary itself sits **above** the component that threw, its own state is
intact. This makes recovery possible. The most common form is a retry action: the
boundary shows a button in the fallback view; the button changes the subtree's identity
and rebuilds it from scratch. The identity changing matters — re-rendering with the same
identity preserves the state that led to the error and produces the same error again.

The second form is redirection: if the detail view collapses because of a corrupted
record, returning to the list view leaves the user on a working screen.

Reporting is done to two separate audiences. The developer receives a record written to a
log: the error message, the stack trace, which component it occurred in, the current
route. The user is shown neither a stack trace nor an error code; a sentence saying what
happened and what can be done is enough. The fallback view also has to be accessible:
announcing the message to a screen reader and moving focus to the retry button follow the
focus management rules from the Web Fundamentals and HTML course.

#### Summary

- An error thrown midway through a render leaves a half-finished tree; writing the
  partial tree produces an inconsistent screen, which is why the default behavior is to
  tear down the tree.
- An error boundary is a component that catches render errors in its subtree and puts a
  fallback view in their place; the error stops at the nearest boundary.
- Boundary placement determines how many components remain on screen: a section-level
  boundary leaves the rest of the page usable, a page-level-only boundary does not.
- A boundary is a call stack mechanism; errors thrown in event handlers, timers, and
  asynchronous callbacks fall outside its scope and are caught there instead.
- Recovery means rebuilding the subtree by changing its identity; re-rendering with the
  same identity produces the same error.
- The log gets the detail, the user gets one sentence and one action; the fallback view
  must also be accessible.

#### Next Step

An error boundary separates a subtree's **failure** from the rest of the tree. There is a
second state that needs the same kind of separation: a subtree that is not yet **ready**.
The measurement table cannot render until data arrives from the server; the comparison
panel waits for the second station's records. Managing this with a separate "loading"
flag in every component produces state combinations that can contradict each other, and
leads every level of a nested wait to spin its own indicator. The next lesson takes up
how the same upward-signaling mechanism as the error boundary is used for waiting, and
where loading views get collected.

### Suspense and Loading States

Source: https://academia.sh/en/courses/component-based-development/suspense-and-loading-states

A component whose data is not ready halting its render and signaling the nearest suspense boundary; the invalid combinations manually held flags produce, the request waterfall boundary placement exposes, measuring spinner flash, and preserving old content during transitions.

The previous lesson separated a subtree's **failure** from the rest of the tree. There is
a second state that needs the same separation: a subtree that is not yet **ready**. The
measurement table cannot render until data arrives from the server; the comparison panel
waits for the second station's records.

The similarity between the two states is structural. In both, a component runs into a
problem it cannot resolve on its own and reports it upward; a boundary above catches the
report and puts another view in the subtree's place. In this lesson, the second mechanism
will be called **suspension**, and the component that catches it a **suspense boundary**.

#### The Problem with Manually Held Flags

The common solution is to hold three separate pieces of state in every component: data, a
loading flag, and an error. Three separate slots produce eight combinations, and only
three of them are meaningful — loading, error, data. The remaining five combinations are
invalid: loading and errored at once, having data while still loading, all three empty at
once.

Invalid combinations do not show up in the code; they show up in edge cases: if the error
flag is not reset when a second request starts, both a spinner and an error message
appear on screen at once. The way to prevent this is to replace the three flags with a
single discriminated state; but then every component still carries its own discriminated
state, its own spinner, and its own error view. If a page has six data-reading
components, six separate spinners spin.

Suspension solves both problems at once: the component holds no state beyond saying "I am
not ready," and the decision about the indicator is collected upward.

#### Suspension

The component tries to read its data. If the data is ready, it takes the value and
continues rendering. If it is not ready, it starts the request and halts the render. The
halt climbs upward, stops at the nearest suspense boundary, and that boundary puts a
waiting view in the subtree's place. When the data arrives, the subtree renders again,
and this time the read succeeds.

```js
// suspension.mjs — a component that is not ready signaling the nearest suspense boundary
const SUSPEND = Symbol("suspend");

function store() {
  const records = new Map();
  const started = new Map();     // the round in which a request started
  let round = 0;
  return {
    openRound: () => { round++; },
    read(key) {
      const entry = records.get(key);
      if (!entry) {
        records.set(key, { status: "pending" });
        started.set(key, round);      // the request starts on the first read attempt
        throw SUSPEND;
      }
      if (entry.status === "pending") throw SUSPEND;
      return entry.data;
    },
    respond(key, data) { records.set(key, { status: "ready", data }); },
    summary: () => [...started].map(([k, r]) => `${k}: round ${r}`).join(", "),
  };
}

const node = (name, opts = {}) => ({ name, boundary: !!opts.boundary, data: opts.data || null, children: opts.children || [] });

function render(n, source) {
  if (n.boundary) {
    try {
      const results = n.children.map((c) => render(c, source));
      return { text: `${n.name}[${results.map((x) => x.text).join(" ")}]`,
               count: 1 + results.reduce((t, x) => t + x.count, 0) };
    } catch (err) {
      if (err !== SUSPEND) throw err;
      return { text: `${n.name}[LOADING]`, count: 1 };
    }
  }
  const label = n.data ? `${n.name}<${source.read(n.data)}>` : n.name;
  if (n.children.length === 0) return { text: label, count: 1 };
  const results = n.children.map((c) => render(c, source));
  return { text: `${label}(${results.map((x) => x.text).join(" ")})`,
           count: 1 + results.reduce((t, x) => t + x.count, 0) };
}

const separateBoundaries = () => node("page", { children: [
  node("title"),
  node("table-boundary", { boundary: true, children: [node("measurement-table", { data: "measurements" })] }),
  node("panel-boundary", { boundary: true, children: [node("comparison-panel", { data: "comparison" })] }),
] });

const sharedBoundary = () => node("page", { children: [
  node("title"),
  node("shared-boundary", { boundary: true, children: [
    node("measurement-table", { data: "measurements" }),
    node("comparison-panel", { data: "comparison" }),
  ] }),
] });

for (const [name, build] of [["two separate suspense boundaries", separateBoundaries], ["one shared suspense boundary", sharedBoundary]]) {
  console.log(name + ":");
  const src = store();
  const rounds = [
    ["first render", null],
    ["measurements arrived", "measurements"],
    ["comparison arrived", "comparison"],
  ];
  for (const [label, arrived] of rounds) {
    src.openRound();
    if (arrived) src.respond(arrived, `${arrived} data`);
    const result = render(build(), src);
    console.log(`  ${label.padEnd(21)} components showing content: ${result.count}`);
    console.log(`    ${result.text}`);
  }
  console.log(`  request start -> ${src.summary()}`);
}
```

```
two separate suspense boundaries:
  first render          components showing content: 4
    page(title table-boundary[LOADING] panel-boundary[LOADING])
  measurements arrived  components showing content: 5
    page(title table-boundary[measurement-table<measurements data>] panel-boundary[LOADING])
  comparison arrived    components showing content: 6
    page(title table-boundary[measurement-table<measurements data>] panel-boundary[comparison-panel<comparison data>])
  request start -> measurements: round 1, comparison: round 1
one shared suspense boundary:
  first render          components showing content: 3
    page(title shared-boundary[LOADING])
  measurements arrived  components showing content: 3
    page(title shared-boundary[LOADING])
  comparison arrived    components showing content: 5
    page(title shared-boundary[measurement-table<measurements data> comparison-panel<comparison data>])
  request start -> measurements: round 1, comparison: round 2
```

The page title appears on the first round in both layouts: waiting halts only the
data-reading subtree, not its siblings. This is the same isolation as in the error
boundary.

#### Boundary Placement and the Request Waterfall

The difference between the two layouts appears on the second round. With separate
boundaries, the measurement table appears the moment its data arrives; with a shared
boundary, the screen does not change at all, because the fallback does not lift until the
entire tree under the boundary is ready. A suspense boundary defines the parts that "need
to appear together."

The last line of the output shows a sneakier difference. With separate boundaries, both
requests started in the first round. With a shared boundary, the measurement table's halt
blocks its sibling from rendering, so the comparison request only starts in the second
round. The second request ends up waiting on the first one's response; two independent
requests become sequential. This is called a **request waterfall** — in the network panel
from The Browser and the Web Platform course, this reads as a stepped, waterfall-shaped
view.

The waterfall's second and more common form arises in nested components: the parent
component reads a station record, and the child reads measurements using the identifier
from that record. The second request is genuinely dependent on the first, and placement
does not fix it. The fix is not letting the request wait for the render to start it: the
data request is started the moment the route resolves, so the request is already on its
way by the time the component renders. The rule is: **if a data requirement can be known
before the render, the request is started before the render.**

#### Spinner Flash

When the waiting view is shown is a separate decision, and it is measurable.

```js
// spinner.mjs — when the loading indicator appears and how long it stays
const RESPONSES = [80, 150, 220, 900];   // response times in ms
const THRESHOLD = 200;      // the indicator never appears for a response arriving before this duration
const MIN_DURATION = 400;   // if the indicator appears, it stays on screen at least this long

function compute(policy, response) {
  if (policy === "immediate") return { starts: 0, ends: response, content: response };
  if (response <= THRESHOLD) return { starts: null, ends: null, content: response };
  if (policy === "delayed") return { starts: THRESHOLD, ends: response, content: response };
  const ends = Math.max(response, THRESHOLD + MIN_DURATION);      // delayed + minimum duration
  return { starts: THRESHOLD, ends, content: ends };
}

for (const policy of ["immediate", "delayed", "delayed+min duration"]) {
  console.log(`${policy}:`);
  for (const response of RESPONSES) {
    const r = compute(policy, response);
    const duration = r.starts === null ? 0 : r.ends - r.starts;
    const line = [
      `  response ${String(response).padStart(3)} ms ->`,
      `indicator ${r.starts === null ? "none" : `${r.starts}-${r.ends} ms`}`.padEnd(20),
      `visible for ${String(duration).padStart(3)} ms`,
      `| content at ${String(r.content).padStart(3)} ms`,
    ].join(" ");
    console.log(duration > 0 && duration < 300 ? `${line}  <- flash` : line);
  }
}
```

```
immediate:
  response  80 ms -> indicator 0-80 ms    visible for  80 ms | content at  80 ms  <- flash
  response 150 ms -> indicator 0-150 ms   visible for 150 ms | content at 150 ms  <- flash
  response 220 ms -> indicator 0-220 ms   visible for 220 ms | content at 220 ms  <- flash
  response 900 ms -> indicator 0-900 ms   visible for 900 ms | content at 900 ms
delayed:
  response  80 ms -> indicator none       visible for   0 ms | content at  80 ms
  response 150 ms -> indicator none       visible for   0 ms | content at 150 ms
  response 220 ms -> indicator 200-220 ms visible for  20 ms | content at 220 ms  <- flash
  response 900 ms -> indicator 200-900 ms visible for 700 ms | content at 900 ms
delayed+min duration:
  response  80 ms -> indicator none       visible for   0 ms | content at  80 ms
  response 150 ms -> indicator none       visible for   0 ms | content at 150 ms
  response 220 ms -> indicator 200-600 ms visible for 400 ms | content at 600 ms
  response 900 ms -> indicator 200-900 ms visible for 700 ms | content at 900 ms
```

Showing the indicator immediately produces a frame on fast responses that appears and
disappears right away. The user perceives this not as loading but as a jump; two state
changes are more jarring than a single wait being visible.

Delayed display never brings up the indicator for responses under the threshold. Yet for
a response just above the threshold, it leaves a twenty-millisecond flash — the worst
form of the problem.

The third policy closes this with a minimum display duration and makes its cost visible:
content ready at two hundred twenty milliseconds is held back until six hundred. This is
a deliberately paid delay; it does not give the user the reward of a fast response, and in
exchange it gives a screen with no jarring flash. The choice among the three policies
depends on the measured response distribution: if most responses fall under the
threshold, the second policy is chosen; if they cluster around the threshold, the third.

#### Designing the Waiting View and Transitions

The waiting view has to hold the place of the content that is coming. A spinner smaller
than the table it will be replaced by makes the page shift down when the data arrives;
this is the **layout shift** defined in the Web Fundamentals and HTML course. In its
place, a skeleton view is used that carries the dimensions of the layout to come: the same
number of rows, the same column widths, the same height.

The second decision is about transitions. The first load and a subsequent load are
different problems. When the user changes the filter, a working table is already on
screen; falling back to the waiting view means erasing that table and reverting to a
skeleton. Emptying a working screen also erases the user's context.

Better behavior keeps the old content in place and adds a waiting indicator — a faded
table, a small indicator in the header, a disabled filter. The rule can be written as: **a
suspense boundary is for filling an empty space, not for emptying a full one.** While the
old content is shown, it has to be marked as stale, or the user will assume they are
looking at updated data.

The accessibility side is not skipped either. Both waiting and completion have to be
announced to the screen reader; a visual indicator alone carries no meaning. The reason
behind disabled controls is also communicated in text.

#### Summary

- Data, loading, and error flags held separately in every component produce eight
  combinations, and five of them are invalid.
- Suspension is a component whose data is not ready halting its render and signaling
  upward; the nearest suspense boundary catches the signal, puts a waiting view in the
  subtree's place, and sibling subtrees continue rendering.
- A suspense boundary defines the parts that need to appear together; independent
  requests gathered under a single boundary become sequential and produce a request
  waterfall.
- If a data requirement can be known before the render, the request is started before the
  render.
- Showing the indicator immediately produces flash; threshold-based display filters out
  the indicator for short responses, and a minimum display duration closes the flash but
  delays the content.
- The waiting view has to carry the dimensions of the content to come; in transitions
  like a filter change, working content is not erased, it is preserved and marked as
  stale.

#### Next Step

The seven lessons so far have determined **when** a subtree renders: when state changes,
when a dependency changes, when an error is caught, when data arrives. One question
remains, and it concerns not time but place: **where** does a subtree render?

The default answer is obvious — a component's position in the component tree determines
its position in the document tree. There is a point on the measurement station page
where this default breaks down. The dropdown list in the filter panel overflows its own
panel's box; the panel's clipping cuts off the list, and the panel's stacking context
leaves the list underneath the table. The component is logically inside the panel but
has to render visually at the very top of the page. The next lesson takes up this split,
the two trees becoming independent of each other, and what this does to event
propagation and accessibility.

### Portals

Source: https://academia.sh/en/courses/component-based-development/transfer

A subtree keeping its place in the component tree while rendering to a different node in the document tree; clipping and stacking constraints, the two trees separating, the event path and context resolution following the component tree, the conditions on the target node, and accessibility obligations.

The previous seven lessons determined **when** a subtree renders: when state changes,
when a dependency changes, when an error is caught, when data arrives. One question
remains, and it concerns not time but place: **where** does a subtree render?

The default answer is obvious. A component's position in the component tree determines
its position in the document tree; the nodes a component produces go inside its parent
component's nodes. There is a point on the measurement station page where this default
does not work: the dropdown list in the filter panel. The list is logically part of the
panel — it reads the panel's state, it produces the panel's events — but visually it
needs to escape the panel's box.

The mechanism this lesson establishes is called a **portal**: keeping a subtree's place
in the component tree while changing its place in the document tree.

#### Two Constraints: Clipping and Stacking

The problem is not a preference; it is two measurable constraints.

```js
// clipping-and-stacking.mjs — the two visual constraints a portal solves
const intersect = (a, b) => ({ top: Math.max(a.top, b.top), bottom: Math.min(a.bottom, b.bottom) });
const height = (rect) => Math.max(0, rect.bottom - rect.top);

const dropdownList = { top: 280, bottom: 520 };     // 240 pixels tall
const filterPanel = { top: 120, bottom: 320 };      // clips overflowing content
const viewport = { top: 0, bottom: 800 };

console.log("clipping:");
for (const [label, clipper] of [["inside the panel", filterPanel], ["portaled (body root)", viewport]]) {
  const clipped = intersect(dropdownList, clipper);
  console.log(
    `  ${label.padEnd(22)} visible ${String(height(clipped)).padStart(3)}/${height(dropdownList)} px`,
    `(${clipped.top}-${clipped.bottom})`,
  );
}

// Stacking chain: each stacking context's z-index value, starting from the root.
const chains = {
  "dropdown-list (inside panel)": [1, 9999],   // the panel establishes its own stacking context
  "dropdown-list (portaled)": [9999],
  "table-header (sticky)": [5],
};

function compare(a, b) {
  const n = Math.max(a.length, b.length);
  for (let i = 0; i < n; i++) {
    const x = i < a.length ? a[i] : -Infinity;
    const y = i < b.length ? b[i] : -Infinity;
    if (x !== y) return x < y ? "BELOW" : "ABOVE";
  }
  return "EQUAL";
}

console.log("stacking order (relative to table header):");
const header = chains["table-header (sticky)"];
for (const label of ["dropdown-list (inside panel)", "dropdown-list (portaled)"]) {
  const z = chains[label];
  console.log(`  ${label.padEnd(30)} chain=[${z.join(", ")}] -> ${compare(z, header)}`);
}
```

```
clipping:
  inside the panel       visible  40/240 px (280-320)
  portaled (body root)   visible 240/240 px (280-520)
stacking order (relative to table header):
  dropdown-list (inside panel)   chain=[1, 9999] -> BELOW
  dropdown-list (portaled)       chain=[9999] -> ABOVE
```

The first constraint is clipping. If the filter panel cuts off content that overflows its
own box, only forty of the list's two hundred forty pixels are visible. This is not a
styling mistake; the clipping the panel's own scroll behavior requires also cuts the
list.

The second constraint is stacking. The **stacking context** defined in the Visual
Presentation with CSS course makes an element's order number meaningful only within its
own context. If the panel establishes its own context, the nine thousand nine hundred
ninety-nine inside it is tied to the panel's one in the root context. The comparison is
made from the chain's first element: one is less than five, so the list stays below the
sticky table header. Raising the number does not help; the raised number is in the wrong
context.

Both constraints arise from a single cause: the subtree's **position** within the
document. The way to change the position is either to break the component tree — tearing
the dropdown list out of the panel's logic and moving it to the page root — or to use a
portal.

#### The Two Trees Separating

A portal leaves the subtree's place in the component tree exactly as it is and changes
only its target in the document tree. The result is two trees that diverge from that
point on.

```js
// portal.mjs — the component tree and document tree separating
// dropdown-list is filter-panel's child in the component tree, and body-root's child in the document tree.
const COMPONENT_PARENT = {
  "option-temperature": "dropdown-list",
  "dropdown-list": "filter-panel",
  "filter-button": "filter-panel",
  "filter-panel": "page",
  "title": "page",
  "measurement-table": "page",
  "page": null,
};

const DOCUMENT_PARENT = {
  "option-temperature": "dropdown-list",
  "dropdown-list": "body-root",
  "body-root": "document",
  "filter-button": "filter-panel",
  "filter-panel": "app-root",
  "title": "app-root",
  "measurement-table": "app-root",
  "app-root": "document",
  "document": null,
};

const PROVIDERS = { page: "celsius" };   // the unit context is provided at the page
const DEFAULT_UNIT = "kelvin";

const path = (map, node) => {
  const trail = [];
  for (let n = node; n; n = map[n]) trail.push(n);
  return trail;
};

const TARGET = "option-temperature";
const componentPath = path(COMPONENT_PARENT, TARGET);
const documentPath = path(DOCUMENT_PARENT, TARGET);

console.log(`target: ${TARGET}`);
console.log(`  component tree path: ${componentPath.join(" > ")}`);
console.log(`  document tree path : ${documentPath.join(" > ")}`);

console.log("click listeners:");
for (const listener of ["filter-panel", "measurement-table", "app-root", "body-root"]) {
  const inComponentPath = componentPath.includes(listener);
  const inDocumentPath = documentPath.includes(listener);
  console.log(
    `  ${listener.padEnd(18)} by component tree: ${inComponentPath ? "fires" : "skips"}`,
    `| by document tree: ${inDocumentPath ? "fires" : "skips"}`,
  );
}

console.log("context resolution (unit):");
for (const [label, trail] of [["component tree", componentPath], ["document tree", documentPath]]) {
  const found = trail.find((n) => n in PROVIDERS);
  console.log(`  ${label.padEnd(15)} -> ${found ? PROVIDERS[found] : DEFAULT_UNIT}`,
              `(${found ? `provider: ${found}` : "no provider"})`);
}
```

```
target: option-temperature
  component tree path: option-temperature > dropdown-list > filter-panel > page
  document tree path : option-temperature > dropdown-list > body-root > document
click listeners:
  filter-panel       by component tree: fires | by document tree: skips
  measurement-table  by component tree: skips | by document tree: skips
  app-root           by component tree: skips | by document tree: skips
  body-root          by component tree: skips | by document tree: fires
context resolution (unit):
  component tree  -> celsius (provider: page)
  document tree   -> kelvin (no provider)
```

Which tree governs context resolution and event propagation is the most surprising
aspect of a portal. The answer is single: **the component tree.**

When an option is clicked, the event climbs the document tree — where the filter panel
does not exist — but the runtime re-dispatches the event along the path in the component
tree. A click listener set up on the filter panel fires. This resembles what the shadow
tree does with the composed path in The Browser and the Web Platform course: physical
propagation happens in one tree, logical propagation in another.

The same holds for context. Even though the portaled subtree ends up outside the provider
in the document tree, it sees the provider in the component tree and reads Celsius.
Resolving through the document tree would have fallen back to the default. The same rule
works for the previous two lessons' mechanisms: a render error in a portaled subtree goes
to the nearest error boundary in the component tree; a suspension in a portaled subtree
is caught by the nearest suspense boundary in the component tree.

This consistency is what makes a portal usable. A portal is not an escape hatch; it
changes only the render target, and breaks none of the logical relationships.

#### Conditions on the Target Node

The target has to satisfy three conditions.

The target has to **exist** in the document when the subtree is written. Placing a
sibling container next to the application's root satisfies this condition in the simplest
way. Producing the target in code and using it at the same moment carries the risk that
the node has not yet been added to the document.

The target's lifetime has to be **longer** than the portaled component's lifetime. If the
target is produced by the portaling component itself, the component takes the target
down with it when removed from the tree, and the teardown order becomes uncertain.

The target has to be **included** in the application's teardown sequence. The portaled
subtree runs its own effects' cleanup normally; but if the target container itself was
produced outside the application, it stays behind in the document as an empty node. Using
a single shared target prevents this accumulation.

If server-side rendering is in use, a fourth condition is added: since no document exists
on the server, portaled content is absent from the initial response and appears only
after the first commit. Content that must be present in the initial view is not
portaled.

#### Accessibility

A portal fixes the visual relationship while severing the relationship in the document,
and this break is real for assistive technologies. Four obligations follow.

**Reading order.** In the document tree, the dropdown list is now at the end of the
page; a screen reader finds it at the end, not next to the button that triggers it. The
link is re-established with attributes that associate the button and the list, and
appropriate ARIA roles.

**Focus management.** When the list opens, focus moves to the list; when it closes,
focus **returns to the button that opened it**. Focus not returning leaves the user at
the end of the document. The focus management rules from the Web Fundamentals and HTML
course apply here unchanged.

**Focus trap.** When a persistent layer is involved — a confirmation dialog — focus is
held inside the layer, and the content behind it is hidden from assistive technologies.
Content that is visually covered is still readable unless it is hidden.

**Dismissal path.** A layer that has stepped outside the tab order still has to be
dismissible by keyboard; the escape key and clicking outside are two separate paths, and
both are set up.

#### When a Portal Is Not Needed

A portal carries a cost: the two trees separating makes debugging harder and gives rise
to the four obligations above. It is not used where it is not needed.

The clipping problem is often solved with layout. Does the panel really need to clip
overflowing content, or is a scroll container placed in the wrong spot? The stacking
problem can also be solved by fixing the chain: freeing the panel from a property that
needlessly establishes a stacking context brings the list back into the root context.

The criterion can be written in one sentence: **a portal is used when a subtree's
position within the document cannot be fixed.** If the position can be fixed, a portal is
not needed.

#### Summary

- A portal keeps a subtree's place in the component tree and changes only its target in
  the document tree.
- Its justification is two measurable constraints: the parent container clipping
  overflowing content, and the stacking context the parent container establishes trapping
  the order number within itself.
- Event propagation, context resolution, error boundaries, and suspense boundaries follow
  the component tree; the break in the document tree does not disturb these
  relationships.
- The target node must exist in the document at the moment of writing, must outlive the
  portaled component, and must be included in the application's teardown sequence.
- A portal disrupts the document's reading order; association attributes, focus
  returning, a focus trap when needed, and a keyboard dismissal path have to be set up
  separately.
- If clipping and stacking can be solved with layout, a portal is not used.

#### Next Step

This topic completed a single component's inner life. We now know what a component
stores across renders, how it synchronizes with the outside world, which values it
computes, which it holds quietly, what it reads from the tree, what happens in error and
waiting states, and where it renders its output. All of it stayed within the boundaries
of a single component.

The measurement station page, however, is not a single component; it is a family made up
of the filter panel, the measurement table, the measurement badge, and the comparison
panel. How these parts connect to one another is a separate question, and its answer is
not only "pass props from parent to child." One component can take another as content,
two components can share a common contract, the same behavior can be reused with
different views. The next topic's first lesson, Composition Patterns, takes up these
ways of combining components and the balance each one strikes between encapsulation and
flexibility.

## Composition and Reuse

Comparing wrapping with slot-based composition, separating logic from view, component families that work together, presentational-container separation, headless components, and designing defaults with prop spreading.

### Composition Patterns

Source: https://academia.sh/en/courses/component-based-development/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.

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.

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

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

### Custom Hooks and Composable Functions

Source: https://academia.sh/en/courses/component-based-development/custom-hooks-and-composable-functions

Units that package state and side effects without producing markup; the call-order rule for position-based hooks, scope cleanup for functions that run once at setup, and a comparison of the two models.

The previous lesson solved the reuse of structure. But structure is not the only thing that
repeats across components. On the North Slope Measurement Station page, the filter panel's
search field triggers on a delay after input, the measurement table holds a sort
preference, and the measurement badge refreshes at an interval. All three have state, all
three have a side effect and that effect's cleanup. All three were written separately.

This logic cannot be moved as long as it stays inside a component's body. To be movable, it
has to be separated from markup, and to be separable, state has to be attachable to a
**call** instead of a component. This lesson's subject is how that call is set up in two
different framework families, and which rule each one imposes.

#### What Gets Separated

A component does two jobs: it manages state and produces a tree from that state. The
second job is markup, and it is specific to the component. The first job is, most of the
time, not specific.

Separation takes this shape: a function that holds state, derived values, and side
effects; that **does not produce** markup; and that returns a handle made of values to read
and functions to call. In the framework family that keeps a virtual tree, this function is
called a **custom hook**; in the family that tracks dependencies at runtime, it is called a
**composable**. Their purpose is the same, their runtime contracts differ.

The test for the separation is a simple question: can two components that call this
function look completely different? If the answer is no, what was separated is not logic
but a hidden component.

#### Position-Bound Slots

In the virtual-tree family, a component is a function and it runs from the start on every
render. Local variables created in one render do not survive into the next. State has to
live outside the component, somewhere the framework holds it.

The cheapest implementation of this is position: a slot array belonging to the component is
kept, and each hook call takes the next slot. A hook's identity is not its name or its
parameters but **which call number it is**.

```js
// hooks.mjs — position-based hook runtime, hook composition, and the call-order rule
let slots = [];
let cursor = 0;

// A single render: reset the cursor, run the component function.
const render = (component, props = {}) => { cursor = 0; return component(props); };

// The one primitive: a position-based state slot.
function useState(initialValue) {
  const i = cursor++;
  if (!(i in slots)) slots[i] = initialValue;
  return [slots[i], (next) => { slots[i] = next; }];
}

// Custom hook: two slots and a validation rule; produces no markup.
function useThreshold(initial) {
  const [threshold, setThreshold] = useState(initial);
  const [touched, setTouched] = useState(false);
  const valid = Number.isFinite(threshold) && threshold >= -40 && threshold <= 40;
  return {
    threshold, valid,
    error: touched && !valid ? "threshold must be between -40 and 40" : null,
    write(next) { setThreshold(next); setTouched(true); },
  };
}

// Second custom hook: sort field and direction.
function useSort(initialField) {
  const [field, setField] = useState(initialField);
  const [ascending, setAscending] = useState(true);
  return {
    field, ascending,
    click(next) { if (next === field) setAscending(!ascending); else { setField(next); setAscending(true); } },
  };
}

// Third custom hook only combines the first two; it has no slot of its own.
const useFilter = () => ({ threshold: useThreshold(-5), sort: useSort("name") });

// Component: calls the hook, returns its view and the handle it hands outward.
function FilterPanel() {
  const f = useFilter();
  const summary =
    `threshold=${f.threshold.threshold} valid=${f.threshold.valid} error=${f.threshold.error ?? "-"} ` +
    `sort=${f.sort.field}/${f.sort.ascending ? "ascending" : "descending"}`;
  return { summary, f };
}

console.log("--- composing two custom hooks: four slots ---");
const c1 = render(FilterPanel);
console.log("render 1:", c1.summary, "| slots:", JSON.stringify(slots));

c1.f.threshold.write(88);
const c2 = render(FilterPanel);
console.log("render 2:", c2.summary, "| slots:", JSON.stringify(slots));

c2.f.sort.click("value");
const c3 = render(FilterPanel);
console.log("render 3:", c3.summary, "| slots:", JSON.stringify(slots));

c3.f.sort.click("value");
const c4 = render(FilterPanel);
console.log("render 4:", c4.summary, "| slots:", JSON.stringify(slots));

// --- Call-order rule ---
console.log("\n--- conditional call: slots shifting ---");
slots = [];
function brokenPanel({ advanced }) {
  const [query] = useState("north");
  if (advanced) useState(-5);                // called only on some renders
  const [sort] = useState("name");
  return { query, sort };
}
console.log("advanced on :", JSON.stringify(render(brokenPanel, { advanced: true })),
  "| slots:", JSON.stringify(slots));
console.log("advanced off:", JSON.stringify(render(brokenPanel, { advanced: false })),
  "| slots:", JSON.stringify(slots));

// Version that applies the rule: the hook is called unconditionally, the condition lives in the returned value.
console.log("\n--- unconditional call, conditional value ---");
slots = [];
function fixedPanel({ advanced }) {
  const [query] = useState("north");
  const [threshold] = useState(-5);
  const [sort] = useState("name");
  return { query, threshold: advanced ? threshold : null, sort };
}
console.log("advanced on :", JSON.stringify(render(fixedPanel, { advanced: true })),
  "| slots:", JSON.stringify(slots));
console.log("advanced off:", JSON.stringify(render(fixedPanel, { advanced: false })),
  "| slots:", JSON.stringify(slots));
```

```
--- composing two custom hooks: four slots ---
render 1: threshold=-5 valid=true error=- sort=name/ascending | slots: [-5,false,"name",true]
render 2: threshold=88 valid=false error=threshold must be between -40 and 40 sort=name/ascending | slots: [88,true,"name",true]
render 3: threshold=88 valid=false error=threshold must be between -40 and 40 sort=value/ascending | slots: [88,true,"value",true]
render 4: threshold=88 valid=false error=threshold must be between -40 and 40 sort=value/descending | slots: [88,true,"value",false]

--- conditional call: slots shifting ---
advanced on : {"query":"north","sort":"name"} | slots: ["north",-5,"name"]
advanced off: {"query":"north","sort":-5} | slots: ["north",-5,"name"]

--- unconditional call, conditional value ---
advanced on : {"query":"north","threshold":-5,"sort":"name"} | slots: ["north",-5,"name"]
advanced off: {"query":"north","threshold":null,"sort":"name"} | slots: ["north",-5,"name"]
```

The first section shows composition working. `useFilter` is a hook with no slot of its own;
it only calls two hooks side by side, and through the fourth render, the four slots carry
the correct values in order. The slot array is a flat list: the hierarchy of nested hook
calls does not appear in it, only the call order does.

That hooks can call each other is this model's strength. A hook does not announce how many
slots it uses internally, and the caller does not need to know either, as long as the order
stays fixed.

#### The Call-Order Rule

The second section shows what happens when that constancy breaks. With `advanced` true,
three hooks are called and the slots become `["north", -5, "name"]`. When the same
component renders with `advanced` false, the middle call is skipped; the sort hook is now
in second position and reads index 1, **which is the first hook's slot**. The result is
that the sort field becomes the number `-5`.

The failure is silent. No exception is thrown, no warning is printed; only the wrong value
is read. In the measurement table, this means the sort silently breaks.

From this comes the one rule of the position-based runtime: **hooks must be called the same
number of times and in the same order on every render.** They cannot be placed inside a
condition, a loop, or an early return. The third section shows how the rule is applied: the
hook is called unconditionally, and the condition is moved into the returned value. The
slot array stays the same across both renders.

The rule has a cost: unused state is still held. With `advanced` off, the threshold slot
stays in memory and keeps its value. This is usually the desired behavior — the threshold
stays put when the user closes and reopens the advanced panel — but it is not a choice; it
is a forced consequence of the model.

#### Functions That Run Once at Setup

In the family that tracks dependencies at runtime, the component function does not re-run
on every update; it runs once, sets up reactive values, and binds the view to those values.
State's identity is not a slot order but the object produced at setup itself.

```js
// composable.mjs — functions that run once at setup time, and cleanup scoping
let activeScope = null;

function openScope(setup) {
  const cleanups = [];
  const previous = activeScope;
  activeScope = { addCleanup: (f) => cleanups.push(f) };
  const result = setup();
  activeScope = previous;
  return {
    result,
    dispose() {                                   // last registered, first disposed
      for (let i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
      cleanups.length = 0;
    },
    pendingCleanups: () => cleanups.length,
  };
}

// Position-independent state: identity is the object itself, not a slot's position.
function state(initialValue) {
  let value = initialValue;
  const listeners = new Set();
  return {
    read: () => value,
    write(next) { value = next; for (const l of [...listeners]) l(next); },
    listen(l) {
      listeners.add(l);
      activeScope?.addCleanup(() => listeners.delete(l));
    },
    listenerCount: () => listeners.size,
  };
}

const log = [];

// Composable 1: threshold and its validation.
function useThreshold(initial) {
  const threshold = state(initial);
  const valid = () => Number.isFinite(threshold.read()) && threshold.read() >= -40 && threshold.read() <= 40;
  threshold.listen((v) => log.push(`threshold written: ${v} (valid=${valid()})`));
  return { threshold, valid };
}

// Composable 2: interval refresh with a fake clock.
const clock = { tasks: [], every: (name, job) => { clock.tasks.push({ name, job }); return () => { clock.tasks = clock.tasks.filter((t) => t.job !== job); }; }, tick: () => clock.tasks.forEach((t) => t.job()) };

function useRefresh(name) {
  const counter = state(0);
  const stop = clock.every(name, () => counter.write(counter.read() + 1));
  activeScope?.addCleanup(() => { stop(); log.push(`${name}: refresh stopped`); });
  return counter;
}

// Composable 3: only combines the other two.
function panelSetup({ advanced }) {
  const thresholdState = useThreshold(-5);
  // Conditional setup is safe: identity is not position-bound.
  const refresh = advanced ? useRefresh("advanced panel") : null;
  return { ...thresholdState, refresh };
}

console.log("--- advanced off ---");
const a = openScope(() => panelSetup({ advanced: false }));
a.result.threshold.write(12);
clock.tick();
console.log("refresh object:", a.result.refresh, "| tasks on the clock:", clock.tasks.length,
  "| pending cleanups:", a.pendingCleanups());

console.log("\n--- advanced on ---");
const b = openScope(() => panelSetup({ advanced: true }));
b.result.threshold.write(88);
clock.tick(); clock.tick();
console.log("refresh counter:", b.result.refresh.read(), "| tasks on the clock:", clock.tasks.length,
  "| pending cleanups:", b.pendingCleanups());
console.log("threshold listeners:", b.result.threshold.listenerCount());

console.log("\n--- after the scope is disposed ---");
b.dispose();
clock.tick();
console.log("tasks on the clock:", clock.tasks.length,
  "| threshold listeners:", b.result.threshold.listenerCount(),
  "| refresh counter:", b.result.refresh.read());

console.log("\nlog:");
for (const s of log) console.log(" ", s);
```

```
--- advanced off ---
refresh object: null | tasks on the clock: 0 | pending cleanups: 1

--- advanced on ---
refresh counter: 2 | tasks on the clock: 1 | pending cleanups: 2
threshold listeners: 1

--- after the scope is disposed ---
tasks on the clock: 0 | threshold listeners: 0 | refresh counter: 2

log:
  threshold written: 12 (valid=true)
  threshold written: 88 (valid=false)
  advanced panel: refresh stopped
```

Conditional setup is safe here. With `advanced` off, the refresh function is never called,
no slot shifts, no value breaks — because identity is bound to the object, not to sequence.
In the off state, the clock has no tasks; in the on state, it has one task, and after two
ticks the counter is 2.

The second distinction is cleanup. Composables register their own cleanup with the
**active scope** at setup time; the caller does not know about this and does nothing
manually. When the scope is disposed, the registrations run in reverse order: the last one
set up is the first one disposed. After disposal, no task remains on the clock and no
listener remains on the threshold; the counter freezes at its last value.

The reverse order is not a detail. If the second function leans on what the first one set
up, the first one must still be standing during disposal. The reverse of the setup order
guarantees this condition every time.

#### The Trade-Off Between the Two Models

The two models meet the same purpose at different costs.

The position-based model keeps no ledger at runtime: a slot array and a cursor are enough.
Its cost is that call order turns into a contract, and that contract cannot be checked by
the language itself. The rule is left to human discipline and static analysis.

The setup-based model does not depend on call order at all; setup can be conditional,
inside a loop, or delayed. Its cost is that every reactive value and every listener is held
as an object: the ledger lives at runtime. The "setup runs once" rule also brings its own
trap — a prop read at setup captures that prop's initial value; tracking later values
requires the read value itself to be reactive.

Three rules are shared by both models. The separated function returns no markup; if it did,
it would be a component. What it takes in and returns must be named; a hook that returns a
two-element array breaks every caller when a third value is added. And the separated
function must be testable on its own: if it can be called and its values read without
rendering a component, the separation has genuinely been made.

#### Summary

- A custom hook and a composable are units that package state and side effects without
  producing markup; they are different contracts for the same purpose across the two
  framework families.
- In a position-based runtime, a hook's identity is its call order; the slot array is flat
  and does not carry the hierarchy of nested hook calls.
- A conditional hook call shifts the slots and the failure is silent: no exception is
  thrown, the wrong value is read. The rule is to call the hook unconditionally and move
  the condition into the returned value.
- In functions that run once at setup, identity is the object; conditional setup is safe,
  and its cost is the ledger kept at runtime.
- Cleanup is registered with the active scope and disposal runs in the reverse of the setup
  order, so that whatever a function leans on is still standing when that function is
  disposed.
- The separated function returns no markup, returns named values, and can be tested without
  rendering a component.

#### Next Step

This lesson took logic out of the component, but it did not address the relationship
between components. The filter panel has a tabbed structure: tab headers, tab bodies, and
the information about which tab is active. Giving this information to every tab as a prop
brings back the prop drilling measured in the previous lesson; letting each tab keep its own
copy allows two tabs to be active at the same time. What is needed is a family written as
separate components that share a single piece of state. The next lesson builds how this
family is set up, how the implicit contract between its members is checked, and how misuse
is kept from staying silent.

### Compound Components

Source: https://academia.sh/en/courses/component-based-development/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.

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.

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

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.

### Presentational and Container Separation

Source: https://academia.sh/en/courses/component-based-development/presentational-and-container-separation

Separating the responsibility that fetches data from the responsibility that produces the view; measuring the separation by the number of test doubles, using a single presentational component with two containers, and where the separation goes too far.

The measurement table currently does four jobs: it fetches measurements from the network,
reads the user's sort preference from storage, sorts the rows, and produces a table tree.
All four live in the body of the same function.

Someone who wants to test how this component looks with an empty table first has to set up
a fake network source. Someone who wants to render the same table with measurements from an
archive file has to change the network call inside the component. Neither request has
anything to do with the view, but both run into the data layer.

#### Two Responsibilities

The separation defines these two roles.

A **presentational component** produces a tree only from the props it receives. It does
not reach the network, does not read the clock, does not look at storage, and does not
take a value from an outer scope. It always gives the same tree for the same props; it
fits the pure function definition from the Programming Fundamentals course.

A **container component** obtains the data, shapes it, and hands it to the presentational
component as props. It produces no markup itself; the only thing it produces is a set of
props. (The term container here refers to this component role, not a CSS flex or grid
container.)

The rule of the separation comes down to one sentence: a presentational component's input
is written **completely** in its signature. If it depends on nothing that is missing from
its signature, the separation has been made.

#### The Measure of the Separation

The gain can be measured by the number of test doubles that have to be set up for testing.

```js
// separation.mjs — testability difference between a combined component and presentational/container separation
import { deepStrictEqual } from "node:assert";

const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter(Boolean) });
const text = (value) => ({ name: "#text", attrs: {}, children: [], text: value });

// --- Combined component: also fetches, sorts, formats, and renders the data ---
async function combinedTable({ source, clock, store }) {
  const field = store.read("sort") ?? "name";
  const raw = await source.fetch();                      // network
  const rows = [...raw].sort((a, b) => (a[field] > b[field] ? 1 : -1));
  const timestamp = new Date(clock.now()).toISOString();  // time
  return element("table", {},
    element("caption", {}, text(`North Slope — ${timestamp}`)),
    element("tbody", {}, ...rows.map((r) =>
      element("tr", { "data-name": r.name },
        element("th", {}, text(r.name)),
        element("td", {}, text(`${r.value.toFixed(1)} °C`))))));
}

// --- Separated version ---
// Presentational component: builds a tree only from its props. No network, no clock, no store.
const tableView = ({ title, timestamp, rows }) =>
  element("table", {},
    element("caption", {}, text(`${title} — ${timestamp}`)),
    element("tbody", {}, ...rows.map((r) =>
      element("tr", { "data-name": r.name },
        element("th", {}, text(r.name)),
        element("td", {}, text(`${r.value.toFixed(1)} °C`))))));

// Container 1: live measurement source.
async function LiveContainer({ source, clock, store }) {
  const field = store.read("sort") ?? "name";
  const raw = await source.fetch();
  return tableView({
    title: "North Slope",
    timestamp: new Date(clock.now()).toISOString(),
    rows: [...raw].sort((a, b) => (a[field] > b[field] ? 1 : -1)),
  });
}

// Container 2: data of the same shape read from an archive file.
function ArchiveContainer({ archiveRows, timestamp }) {
  return tableView({
    title: "North Slope",
    timestamp,
    rows: [...archiveRows].sort((a, b) => (a.name > b.name ? 1 : -1)),
  });
}

// --- Test: how many test doubles are needed? ---
const MEASUREMENTS = [{ name: "Upper Slope", value: -4.2 }, { name: "Lower Slope", value: 1.63 }];
const TIMESTAMP = "2026-02-11T06:00:00.000Z";

let fake = 0;
const fakeSource = (v) => { fake++; return { fetch: async () => v }; };
const fakeClock = (t) => { fake++; return { now: () => t }; };
const fakeStore = (d) => { fake++; return { read: (a) => d[a] }; };

fake = 0;
const combinedTree = await combinedTable({
  source: fakeSource(MEASUREMENTS), clock: fakeClock(Date.parse(TIMESTAMP)), store: fakeStore({ sort: "name" }),
});
const combinedFakes = fake;

fake = 0;
const viewTree = tableView({
  title: "North Slope", timestamp: TIMESTAMP,
  rows: [{ name: "Lower Slope", value: 1.63 }, { name: "Upper Slope", value: -4.2 }],
});
const viewFakes = fake;

deepStrictEqual(combinedTree, viewTree);
console.log("combined component's tree matches presentational component's tree: yes");
console.log(`test doubles required — combined: ${combinedFakes}, presentational: ${viewFakes}`);
console.log(`does the test need to be asynchronous — combined: yes, presentational: no`);

// Purity: two calls with the same props give the same tree.
const a = tableView({ title: "North Slope", timestamp: TIMESTAMP, rows: MEASUREMENTS });
const b = tableView({ title: "North Slope", timestamp: TIMESTAMP, rows: MEASUREMENTS });
deepStrictEqual(a, b);
console.log("presentational component produces the same tree with the same props: yes");

// Two containers, one presentational component.
fake = 0;
const live = await LiveContainer({
  source: fakeSource(MEASUREMENTS), clock: fakeClock(Date.parse(TIMESTAMP)), store: fakeStore({}),
});
const liveFakes = fake;
fake = 0;
const archive = ArchiveContainer({ archiveRows: MEASUREMENTS, timestamp: TIMESTAMP });
const archiveFakes = fake;
deepStrictEqual(live, archive);
console.log(`\nthe tree produced by both containers is identical: yes (live fakes=${liveFakes}, archive fakes=${archiveFakes})`);

// Concurrent countability of view states
const states = [
  ["full table", { title: "North Slope", timestamp: TIMESTAMP, rows: MEASUREMENTS }],
  ["single row", { title: "North Slope", timestamp: TIMESTAMP, rows: [MEASUREMENTS[0]] }],
  ["empty table", { title: "North Slope", timestamp: TIMESTAMP, rows: [] }],
  ["negative and zero", { title: "North Slope", timestamp: TIMESTAMP, rows: [{ name: "Summit", value: -0.04 }] }],
];
console.log("\nstate               rows  first cell");
for (const [name, props] of states) {
  const tree = tableView(props);
  const body = tree.children[1];
  const first = body.children[0]?.children[1].children[0].text ?? "-";
  console.log(`${name.padEnd(19)} ${String(body.children.length).padStart(4)}  ${first}`);
}
```

```
combined component's tree matches presentational component's tree: yes
test doubles required — combined: 3, presentational: 0
does the test need to be asynchronous — combined: yes, presentational: no
presentational component produces the same tree with the same props: yes

the tree produced by both containers is identical: yes (live fakes=3, archive fakes=0)

state               rows  first cell
full table             2  -4.2 °C
single row             1  -4.2 °C
empty table            0  -
negative and zero      1  -0.0 °C
```

Three test doubles against zero. Testing the combined component requires faking the
network source, the clock, and the store; none of the three has anything to do with the
view. None of them is needed for the presentational component, because all three stand as
a prop in its signature.

The count of test doubles is not the only gain. The combined component is asynchronous;
testing it has to wait on a promise and carries timing-related fragility. The
presentational component is synchronous: it is called and its result is examined.

#### The Container Changes, the View Does Not

The second section shows the real gain of the separation. The live container reads from
the network and asks for three test doubles; the archive container uses the array it
already has and asks for none. The two produce an identical tree.

This means the same view can be used on the station page with live measurements, on the
archive page with past measurements, and on a server-prerendered page with measurements
read from a file. The view component knows nothing about any of these three contexts.

The rule is this: the container answers the question of **where from**, the presentational
component answers the question of **how it looks**. Because the two questions change
independently, the two components change independently.

#### What the Separation Makes Visible

The final table shows that the separation gains something beyond testing. Four view states
were produced without a network, in seconds, side by side. The fourth one exposed a flaw:
-0.04 degrees Celsius, rounded to one decimal place, becomes `-0.0 °C`.

This is a concrete example of the Functional Colors and microcopy discussions in the
Fundamentals of Interface Design course: a zero carrying a negative sign tells the reader
the temperature is below freezing, when the value is in fact nearly exactly zero. The flaw
is a formatting decision and has nothing to do with the network layer — but in the combined
component, it could only have been seen by setting up a fake source and placing the right
data in it.

Being able to produce view states cheaply means the design side's state table can actually
be checked. The five interaction states defined in the Component States topic in the
Fundamentals of Interface Design course, the loading and empty states, the error states —
all of these are a set of props, and all of them can be produced with zero test doubles.

#### Where the Separation Does Not Pay Off

Splitting every component in two is not a gain.

If a component has no outside dependency at all, it is already a presentational component;
adding a container to it only adds a file and a layer of indirection. The measurement
badge is an example: its input is already the measurement's name, value, and threshold.

The reverse is also true. Separating out a view that is used in only one place and will
never be reused forces going back and forth between two files and raises the cost of
reading. The separation pays off once a second use or a view state that needs testing
shows up.

A third misapplication is doing the separation at the **file level** while missing the
dependency. If a presentational component sits in its own file but reads the clock inside
it, or takes a format setting from a global value, the separation has not been made; the
test-double count does not drop. The measure is not the number of files but the
completeness of the signature.

Another shape of the separation is that the container itself is not even a component: the
custom hook or composable built in the previous lesson takes on obtaining the data, and the
component only calls it and hands the result to the presentational component. The
separation of responsibility is the same; what changes is which unit the separation is done
in.

#### Summary

- A presentational component produces a tree only from its props; it does not read the
  network, the clock, storage, or an outer scope. The container obtains the data and turns
  it into a set of props.
- The measure of the separation is the number of test doubles that have to be set up for
  testing; the example came out three against zero, and the presentational component's
  test stayed synchronous.
- A single presentational component produces the same tree with different containers; live
  data, archive data, and a prerendered page share the same view.
- Being able to produce view states cheaply makes formatting flaws visible without ever
  touching the network layer.
- Adding a container to a component with no outside dependency only adds indirection; the
  separation pays off once a second use or a state that needs testing shows up.
- The measure is not the number of files but the completeness of the signature; if a
  component in a separate file reads a global value, the separation has not been made.

#### Next Step

This lesson separated data from view, but it did not separate behavior. Looking at the
option list in the filter panel, what remains is this: navigating with arrow keys, jumping
to the first and last item, going to a matching item when a letter is pressed, selecting,
and closing. This behavior is the same everywhere, but the list's view is different
everywhere — a dropdown in one place, a side panel in another, a set of tags in a third.
Embedding the behavior in one component fixes the view along with it. The next lesson
pulls the behavior away from the view and shows that what is needed for this is not a
component but a pure state machine.

### Headless Components

Source: https://academia.sh/en/courses/component-based-development/headless-components

Detaching behavior from view; writing the option list as a pure state machine, deriving accessibility attributes from state, testing it with event sequences, and using the same behavior with two views.

The filter panel has a list for selecting a station. The list's behavior is detailed:
navigating with arrow keys, skipping a disabled item, wrapping around at the end of the
list, jumping to the first and last item, going to a matching item when a letter is
pressed, selecting, canceling. This behavior also carries an accessibility contract —
which item is active, whether the list is open, and which item is selected all have to be
announced to assistive technology.

The view, however, differs everywhere. In the filter panel it is a dropdown attached to a
button, on a wide screen it is a list that stays open in a side panel, in the measurement
table's header it is an inline selector. Embedding the behavior in one component means
collapsing these three views into a single view.

#### Behavior Is Not a Component

A **headless component** is a unit that carries behavior and an accessibility contract but
produces no view at all. It gives back two things: a state to read and attributes to
attach to markup. It picks no element, writes no class, defines no style.

It is one step past the separation from the previous lesson. Presentational and container
separation separated data from view; here what gets separated is behavior. What remains is
three independent layers: one that obtains the data, one that runs the behavior, and one
that produces the view.

The carrier of the behavior is a state machine. A reducer that fits the pure function
definition from the Programming Fundamentals course — it takes a state and an event,
returns a new state — can carry the whole behavior. The timer, DOM access, and network
call stay outside this function.

```js
// headless.mjs — the option list's behavior: a pure state machine, no view
export const OPTIONS = [
  { value: "upper", label: "Upper Slope" },
  { value: "middle", label: "Middle Terrace" },
  { value: "lower", label: "Lower Slope", disabled: true },
  { value: "summit", label: "Summit" },
];

export const initialState = (selected = null) =>
  ({ open: false, active: -1, selected, buffer: "", lastLetterTime: -Infinity });

const isSelectable = (o, i) => i >= 0 && i < o.length && !o[i].disabled;
const edge = (o, fromStart) =>
  (fromStart ? o.map((_, i) => i) : o.map((_, i) => o.length - 1 - i)).find((i) => isSelectable(o, i)) ?? -1;
const advance = (o, startIndex, direction) => {
  if (startIndex < 0) return edge(o, direction > 0);
  for (let step = 1; step <= o.length; step++) {
    const i = (startIndex + direction * step + o.length * step) % o.length;
    if (isSelectable(o, i)) return i;
  }
  return -1;
};

// Pure reducer: (state, event) -> new state. No side effect, no timer, no DOM.
export function reduce(state, event, options = OPTIONS) {
  const openList = () => {
    const selectedIndex = options.findIndex((o) => o.value === state.selected);
    return {
      ...state, open: true, buffer: "",
      active: isSelectable(options, selectedIndex) ? selectedIndex : edge(options, true),
    };
  };
  const closeList = () => ({ ...state, open: false, active: -1, buffer: "" });

  switch (event.type) {
    case "open": return state.open ? state : openList();
    case "close": return closeList();
    case "mark":
      return state.open && isSelectable(options, event.index) ? { ...state, active: event.index } : state;
    case "key":
      switch (event.key) {
        case "ArrowDown": return state.open ? { ...state, active: advance(options, state.active, +1) } : openList();
        case "ArrowUp": return state.open ? { ...state, active: advance(options, state.active, -1) } : openList();
        case "Home": return state.open ? { ...state, active: edge(options, true) } : state;
        case "End": return state.open ? { ...state, active: edge(options, false) } : state;
        case "Escape": return closeList();
        case "Enter": case " ":
          if (!state.open) return openList();
          if (!isSelectable(options, state.active)) return state;
          return { ...closeList(), selected: options[state.active].value };
        default: return state;
      }
    case "letter": {
      const next = event.time - state.lastLetterTime > 500 ? event.letter : state.buffer + event.letter;
      const lower = (m) => m.toLocaleLowerCase("en-US");
      const found = options.findIndex((o, i) => isSelectable(options, i) && lower(o.label).startsWith(lower(next)));
      return {
        ...state, open: true, buffer: next, lastLetterTime: event.time,
        active: found === -1 ? state.active : found,
      };
    }
    default: return state;
  }
}

// Accessibility attributes derived from state: still not a view, but a contract.
export const aria = (state, root, options = OPTIONS) => ({
  trigger: {
    "aria-haspopup": "listbox",
    "aria-expanded": String(state.open),
    "aria-controls": `${root}-list`,
  },
  list: {
    id: `${root}-list`,
    role: "listbox",
    tabindex: -1,
    "aria-activedescendant": state.open && state.active >= 0 ? `${root}-option-${state.active}` : "",
  },
  option: (i) => ({
    id: `${root}-option-${i}`,
    role: "option",
    "aria-selected": String(options[i].value === state.selected),
    ...(options[i].disabled ? { "aria-disabled": "true" } : {}),
  }),
});
```

The state has five fields and none of them belongs to the view: whether the list is open,
which item is active, which value is selected, what the typeahead buffer holds, and when
the last letter was entered. Even time is a field — the reducer does not read the clock, it
receives it inside the event. This makes the test independent of time.

Accessibility attributes are written as a derivative of state. The active item does not take focus; the
list stays focused and the active item's identity is announced through `aria-activedescendant`. This is an
alternative to the roving tabindex arrangement from The Browser and the Web Platform course: a single stop
in keyboard order and arrow-key navigation inside the list are common to both, but focus is never moved
between the items.

#### Same Behavior, Two Views

The following file goes in the same directory as `headless.mjs` from the previous block.

```js
// headless-trace.mjs — the same state machine, two separate views
import { OPTIONS, initialState, reduce, aria } from "./headless.mjs";

const events = [
  { type: "key", key: "ArrowDown" },
  { type: "key", key: "ArrowDown" },
  { type: "key", key: "ArrowDown" },
  { type: "letter", letter: "s", time: 1000 },
  { type: "key", key: "Home" },
  { type: "key", key: "ArrowUp" },
  { type: "key", key: "Enter" },
  { type: "key", key: "ArrowDown" },
  { type: "key", key: "Escape" },
];

let state = initialState();
console.log("event                open  active  active label     selected  buffer");
console.log(`${"(start)".padEnd(20)} ${String(state.open).padEnd(5)} ${String(state.active).padStart(6)}  ${"-".padEnd(15)} ${String(state.selected).padEnd(8)} "${state.buffer}"`);
for (const event of events) {
  state = reduce(state, event);
  const eventLabel = event.type === "key" ? `key ${event.key}` : event.type === "letter" ? `letter "${event.letter}"` : event.type;
  const activeLabel = state.active >= 0 ? OPTIONS[state.active].label : "-";
  console.log(`${eventLabel.padEnd(20)} ${String(state.open).padEnd(5)} ${String(state.active).padStart(6)}  ${activeLabel.padEnd(15)} ${String(state.selected).padEnd(8)} "${state.buffer}"`);
}

// --- Same state, two views ---
const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter(Boolean) });
const text = (v) => ({ name: "#text", attrs: {}, children: [], text: v });
const printTree = (node, depth = 0) => {
  if (node.text !== undefined) return [`${"  ".repeat(depth)}"${node.text}"`];
  const attrsText = Object.entries(node.attrs).filter(([, v]) => v !== "").map(([a, v]) => ` ${a}="${v}"`).join("");
  return [`${"  ".repeat(depth)}<${node.name}${attrsText}>`, ...node.children.flatMap((c) => printTree(c, depth + 1))];
};

// View A: dropdown attached to a button.
const Dropdown = (state, root) => {
  const a = aria(state, root);
  const selected = OPTIONS.find((o) => o.value === state.selected);
  return element("div", { class: "dropdown" },
    element("button", { type: "button", ...a.trigger }, text(selected?.label ?? "Select a station")),
    state.open && element("ul", { ...a.list, class: "dropdown-list" },
      ...OPTIONS.map((o, i) => element("li", { ...a.option(i) }, text(o.label)))));
};

// View B: side panel, always open.
const SidePanel = (state, root) => {
  const a = aria({ ...state, open: true }, root);
  return element("nav", { class: "side-panel" },
    element("h2", { id: `${root}-heading` }, text("Station")),
    element("ul", { ...a.list, "aria-labelledby": `${root}-heading`, class: "panel-list" },
      ...OPTIONS.map((o, i) => element("li", { ...a.option(i) }, text(o.label)))));
};

let demoState = initialState();
for (const event of [{ type: "open" }, { type: "key", key: "ArrowDown" }]) demoState = reduce(demoState, event);

console.log("\nview A (dropdown), state: open, active =", demoState.active);
console.log(printTree(Dropdown(demoState, "station")).join("\n"));

// Compare the two views: role and state attributes are the same, element names and classes differ.
const walk = (d, c = []) => { if (d.text === undefined) { c.push(d); d.children.forEach((x) => walk(x, c)); } return c; };
const signature = (root, on) => walk(root).map((d) => {
  const role = d.attrs.role ?? d.name;
  const stateAttrs = Object.entries(d.attrs)
    .filter(([a]) => a.startsWith("aria-"))
    .map(([a, v]) => `${a}=${String(v).replace(on, "*")}`).sort().join(",");
  return `${role}${stateAttrs ? "[" + stateAttrs + "]" : ""}`;
});
const a = signature(Dropdown(demoState, "station"), /station/g);
const b = signature(SidePanel(demoState, "station-side"), /station-side/g);
console.log("\nview A role/state signature:", JSON.stringify(a));
console.log("view B role/state signature:", JSON.stringify(b));
console.log("element names:", JSON.stringify(walk(Dropdown(demoState, "station")).map((d) => d.name)),
  "/", JSON.stringify(walk(SidePanel(demoState, "station-side")).map((d) => d.name)));
```

```
event                open  active  active label     selected  buffer
(start)              false     -1  -               null     ""
key ArrowDown        true       0  Upper Slope     null     ""
key ArrowDown        true       1  Middle Terrace  null     ""
key ArrowDown        true       3  Summit          null     ""
letter "s"           true       3  Summit          null     "s"
key Home             true       0  Upper Slope     null     "s"
key ArrowUp          true       3  Summit          null     "s"
key Enter            false     -1  -               summit   ""
key ArrowDown        true       3  Summit          summit   ""
key Escape           false     -1  -               summit   ""

view A (dropdown), state: open, active = 1
<div class="dropdown">
  <button type="button" aria-haspopup="listbox" aria-expanded="true" aria-controls="station-list">
    "Select a station"
  <ul id="station-list" role="listbox" tabindex="-1" aria-activedescendant="station-option-1" class="dropdown-list">
    <li id="station-option-0" role="option" aria-selected="false">
      "Upper Slope"
    <li id="station-option-1" role="option" aria-selected="false">
      "Middle Terrace"
    <li id="station-option-2" role="option" aria-selected="false" aria-disabled="true">
      "Lower Slope"
    <li id="station-option-3" role="option" aria-selected="false">
      "Summit"

view A role/state signature: ["div","button[aria-controls=*-list,aria-expanded=true,aria-haspopup=listbox]","listbox[aria-activedescendant=*-option-1]","option[aria-selected=false]","option[aria-selected=false]","option[aria-disabled=true,aria-selected=false]","option[aria-selected=false]"]
view B role/state signature: ["nav","h2","listbox[aria-activedescendant=*-option-1,aria-labelledby=*-heading]","option[aria-selected=false]","option[aria-selected=false]","option[aria-disabled=true,aria-selected=false]","option[aria-selected=false]"]
element names: ["div","button","ul","li","li","li","li"] / ["nav","h2","ul","li","li","li","li"]
```

The trace table shows the entire behavior in ten rows. On the third arrow key, the active
index jumps from 1 to 3: the disabled item is skipped. The `Home` key goes to the start;
from there, the up arrow again skips the disabled item and wraps to the end. `Enter` makes
the selection and closes the list; the arrow key that follows opens the list with the
selected item active. In the last row, `Escape` closes the list but does not break the
selection.

The two views' role signatures are element-for-element identical: four options, the same
selection states, the same disabled announcement, the same active item. The difference is
only in the enclosing structure — one is a dropdown attached to a button, the other is a
side panel bound to a heading. The element names differ too. The behavior stays the same
while the view changes independently.

#### Testing Behavior Without a View

The most concrete gain of the headless approach shows up here: behavior is tested without a
single view being built.

```js
// headless.test.mjs — testing the behavior; no view, no DOM is set up
import { test } from "node:test";
import { strictEqual, deepStrictEqual } from "node:assert";
import { OPTIONS, initialState, reduce, aria } from "./headless.mjs";

const play = (events, state = initialState()) => events.reduce((s, e) => reduce(s, e), state);
const key = (k) => ({ type: "key", key: k });

test("arrow key skips a disabled option", () => {
  const d = play([key("ArrowDown"), key("ArrowDown"), key("ArrowDown")]);
  strictEqual(OPTIONS[d.active].value, "summit");
});

test("wraps to the start after the last option, and to the end when up is pressed at the start", () => {
  const atEnd = play([key("End")], { ...initialState(), open: true });
  strictEqual(atEnd.active, 3);
  strictEqual(play([key("ArrowDown")], atEnd).active, 0);
  strictEqual(play([key("ArrowUp")], { ...atEnd, active: 0 }).active, 3);
});

test("Enter selects and closes, Escape does not change the selection", () => {
  const selected = play([key("ArrowDown"), key("ArrowDown"), key("Enter")]);
  strictEqual(selected.selected, "middle");
  strictEqual(selected.open, false);
  const escaped = play([key("ArrowDown"), key("Escape")], selected);
  strictEqual(escaped.selected, "middle");
  strictEqual(escaped.open, false);
});

test("the selected option becomes active on open", () => {
  strictEqual(play([{ type: "open" }], initialState("summit")).active, 3);
  strictEqual(play([{ type: "open" }], initialState(null)).active, 0);
});

test("the letter buffer accumulates within 500 ms, then resets", () => {
  const a = play([{ type: "letter", letter: "m", time: 1000 }]);
  strictEqual(OPTIONS[a.active].value, "middle");
  const b = reduce(a, { type: "letter", letter: "i", time: 1200 });   // "mi" -> Middle Terrace
  strictEqual(b.buffer, "mi");
  const c = reduce(b, { type: "letter", letter: "s", time: 2000 });   // buffer reset
  strictEqual(c.buffer, "s");
  strictEqual(OPTIONS[c.active].value, "summit");
});

test("a disabled option cannot be marked or selected", () => {
  const opened = play([{ type: "open" }]);
  strictEqual(reduce(opened, { type: "mark", index: 2 }).active, opened.active);
  const forced = { ...opened, active: 2 };
  strictEqual(reduce(forced, key("Enter")).selected, null);
});

test("the reducer does not mutate the given state", () => {
  const before = play([{ type: "open" }]);
  const copy = structuredClone(before);
  reduce(before, key("ArrowDown"));
  deepStrictEqual(before, copy);
});

test("the active descendant is announced only while the list is open", () => {
  const closed = initialState("middle");
  strictEqual(aria(closed, "k").list["aria-activedescendant"], "");
  strictEqual(aria(closed, "k").trigger["aria-expanded"], "false");
  const opened = reduce(closed, { type: "open" });
  strictEqual(aria(opened, "k").list["aria-activedescendant"], "k-option-1");
  strictEqual(aria(opened, "k").option(1)["aria-selected"], "true");
  strictEqual(aria(opened, "k").option(2)["aria-disabled"], "true");
});
```

The file is run with `node --test headless.test.mjs`. In the output below, the duration
values vary by machine; the test results do not.

{/* mask: (?<=\()[0-9.]+(?=ms\)) */}
{/* mask: (?<=duration_ms )[0-9.]+ */}
```
✔ arrow key skips a disabled option (0.42275ms)
✔ wraps to the start after the last option, and to the end when up is pressed at the start (0.060709ms)
✔ Enter selects and closes, Escape does not change the selection (0.061709ms)
✔ the selected option becomes active on open (0.342541ms)
✔ the letter buffer accumulates within 500 ms, then resets (0.077958ms)
✔ a disabled option cannot be marked or selected (0.048459ms)
✔ the reducer does not mutate the given state (0.315ms)
✔ the active descendant is announced only while the list is open (0.100875ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 37.723209
```

Eight tests run with no browser and no fake DOM at all. The typeahead test supplies time as
a plain number; because no real timer is waited on, the test runs in microseconds instead
of milliseconds. The seventh test checks the immutability of state: if the reducer mutated
the given object, the framework's change-detection mechanism would break too.

#### The Limits of the Headless Approach

The cost of the headless approach is more work for the side that builds the view.
Attaching attributes to markup, binding events to the right element, and scrolling the
active item into view all become the caller's responsibility. This work is repeated on
every use, and it gets done incompletely somewhere.

The line is this: writing headless pays off when the behavior will be used with more than
one view; if it will only be used with a single view, keeping that same behavior inside one
component means less surface. A middle path is to put a default view on top of the headless
core: the ready-made component is used in the common case, and the core is dropped down to
when needed.

The second limit is that some behaviors cannot stay pure. Scrolling the active item into
view, positioning the dropdown against the screen edge, and returning focus to the trigger
when the list closes all require measurement and DOM access. These stay outside the state
machine, in a separate and explicitly marked layer; the machine's purity is preserved.

#### Summary

- A headless component carries behavior and an accessibility contract and produces no
  view; it gives back a state to read and attributes to attach to markup.
- The carrier of the behavior is a pure reducer; even time is supplied inside the event, so
  the test stays independent of time.
- Accessibility attributes are a derivative of state; the active item does not take focus,
  its identity is announced through `aria-activedescendant`.
- When two different views are produced from the same state, the role and state signatures
  stay the same while the element names and enclosing structure change.
- Behavior is tested with event sequences without building a single view; the reducer not
  mutating state is itself a property worth testing.
- Behaviors that require measurement and focus movement stay outside the machine, in a
  separate layer; the headless approach does not pay off for single-view use.

#### Next Step

Everything built in this topic rested on the component's outer surface: which props it
takes, which values it gives back, which hole it leaves open. That surface itself has not
been designed yet. What happens when the caller gives the component an attribute it does
not recognize? When 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? Who owns the value — the component or the caller? The next lesson
gathers the answers to these questions under prop spreading, default merging, and
ownership of control.

### Component API Design

Source: https://academia.sh/en/courses/component-based-development/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.

## Framework Families

The update models, costs, and selection criteria of framework families that hold a virtual tree, track dependencies at runtime, compile reactivity at compile time, and offer an integrated contract.

### Virtual-Tree-Based Frameworks

Source: https://academia.sh/en/courses/component-based-development/virtual-tree-based-frameworks

The update model that produces a new tree from state and compares it with the previous one; counting the work a one-cell change produces, the key's role in reconciliation, and memoization's trade-off.

Throughout the previous topic, components were composed on paper: a function produced a
tree, trees nested inside each other, props merged. How the produced tree reaches the
document was never examined.

There is no single answer. Frameworks take distinct paths to find which part of the
document needs updating when state changes, and that path shapes everything about the
framework, from its learning curve to its performance profile. This topic covers all four
paths. The first treats updating as a **comparison** problem.

#### The Re-Render Unit Is the Component

The model works like this. A component's state changes; the framework reruns that
component's function, which produces a new tree from the current state. This tree is not
the document — it is an in-memory structure describing the document, called the **virtual
tree**. The framework compares the new tree with the previous one, finds the differences,
and writes only those to the document.

The model's gain is that whoever writes the component never touches the document. The
one-way flow from the Declarative Rendering lesson holds here in its full sense: there is a
function from state to view, and no function the other way.

The cost is captured in one sentence: **the re-render unit is the component.** A change in
one component's state causes the entire tree that component produces to be regenerated and
compared, no matter how small the changed part is.

#### The Cost of a One-Cell Change

The measurement table on the station page has two hundred rows. A single sensor's value is
updated. The work this produces can be counted.

```js
// virtual-tree.mjs — re-render, diffing, and the cost of a one-cell change
let counter;
const reset = () => (counter = { component: 0, node: 0, attribute: 0, patch: 0, skipped: 0 });

const h = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter((c) => c != null) });
const text = (value) => ({ name: "#text", attrs: {}, children: [], text: String(value) });

// --- Components: rerun on every render and produce a new tree ---
const Cell = ({ value }) => { counter.component++; return h("td", { class: "value" }, text(`${value.toFixed(1)} °C`)); };
const Row = ({ name, value }) => {
  counter.component++;
  return h("tr", { key: name }, h("th", { scope: "row" }, text(name)), Cell({ value }));
};
const Table = ({ rows, draw }) => {
  counter.component++;
  return h("table", { class: "measurement" },
    h("caption", {}, text("North Slope")),
    h("tbody", {}, ...rows.map(draw)));
};

// --- Memoization: a component whose props have not changed returns its previous tree unchanged ---
function memoize(component) {
  const cache = new Map();
  return (o) => {
    const key = o.name;
    const entry = cache.get(key);
    if (entry && Object.keys(o).every((a) => entry.props[a] === o[a])) { counter.skipped++; return entry.tree; }
    const tree = component(o);
    cache.set(key, { props: { ...o }, tree });
    return tree;
  };
}

// --- Diffing ---
function diff(old, next) {
  if (old === next) { counter.node++; return; }        // same reference: subtree is never visited
  counter.node++;
  if (old.name !== next.name) { counter.patch++; return; }
  if (old.text !== undefined || next.text !== undefined) {
    if (old.text !== next.text) counter.patch++;
    return;
  }
  for (const a of new Set([...Object.keys(old.attrs), ...Object.keys(next.attrs)])) {
    counter.attribute++;
    if (old.attrs[a] !== next.attrs[a]) counter.patch++;
  }
  const keyed = next.children.length > 0 && next.children.every((c) => c.attrs?.key !== undefined);
  if (!keyed) {
    const n = Math.max(old.children.length, next.children.length);
    for (let i = 0; i < n; i++) {
      if (!old.children[i] || !next.children[i]) { counter.patch++; continue; }
      diff(old.children[i], next.children[i]);
    }
    return;
  }
  const oldMap = new Map(old.children.map((c, i) => [c.attrs.key, { node: c, index: i }]));
  const oldOrder = next.children.map((c) => oldMap.get(c.attrs.key)?.index ?? -1);
  const keep = longestIncreasingSubsequence(oldOrder);   // nodes that do not need to move
  next.children.forEach((c, i) => {
    const found = oldMap.get(c.attrs.key);
    if (!found) { counter.patch++; return; }            // new node: inserted
    if (!keep.has(i)) counter.patch++;                  // minimal number of moves
    diff(found.node, c);
    oldMap.delete(c.attrs.key);
  });
  counter.patch += oldMap.size;                          // remaining ones are removed
}

// Longest increasing subsequence: gives the indices of the nodes that do not need to move.
function longestIncreasingSubsequence(array) {
  const prev = new Array(array.length).fill(-1);
  const tails = [];
  for (let i = 0; i < array.length; i++) {
    if (array[i] < 0) continue;
    let lo = 0, hi = tails.length;
    while (lo < hi) { const mid = (lo + hi) >> 1; if (array[tails[mid]] < array[i]) lo = mid + 1; else hi = mid; }
    if (lo > 0) prev[i] = tails[lo - 1];
    tails[lo] = i;
  }
  const result = new Set();
  for (let k = tails[tails.length - 1]; k !== undefined && k !== -1; k = prev[k]) result.add(k);
  return result;
}

const report = (label) =>
  console.log(`${label.padEnd(36)} ${String(counter.component).padStart(9)} ${String(counter.skipped).padStart(8)} ` +
    `${String(counter.node).padStart(9)} ${String(counter.attribute).padStart(11)} ${String(counter.patch).padStart(5)}`);

const ROW_COUNT = 200;
const data = (shift = 0) =>
  Array.from({ length: ROW_COUNT }, (_, i) => ({ name: `sensor-${String(i).padStart(3, "0")}`, value: -10 + i * 0.1 + shift }));

console.log("scenario                            component  skipped      node    attribute  patch");

// 1. No memoization: a single cell changes.
reset();
const v1 = data();
const old1 = Table({ rows: v1, draw: Row });
const v2 = v1.map((s, i) => (i === 120 ? { ...s, value: 42.5 } : s));
const next1 = Table({ rows: v2, draw: Row });
diff(old1, next1);
report("no memoization, one cell changed");

// 2. Memoization: rows whose props have not changed are skipped.
reset();
const memoizedRow = memoize(Row);
const old2 = Table({ rows: v1, draw: memoizedRow });
const next2 = Table({ rows: v2, draw: memoizedRow });
diff(old2, next2);
report("memoized, one cell changed");

// 3. Nothing changed (the parent component rendered for another reason).
reset();
const old3 = Table({ rows: v1, draw: Row });
const next3 = Table({ rows: v1, draw: Row });
diff(old3, next3);
report("no memoization, nothing changed");

// 4. A row was prepended — keyed reconciliation.
reset();
const newRow = { name: "sensor-new", value: 3.3 };
const old4 = Table({ rows: v1, draw: Row });
const next4 = Table({ rows: [newRow, ...v1], draw: Row });
diff(old4, next4);
report("row prepended (keyed)");

// 5. The same insertion, without a key: every row shifts, so the comparison does not match.
reset();
const UnkeyedRow = ({ name, value }) => {
  counter.component++;
  return h("tr", {}, h("th", { scope: "row" }, text(name)), Cell({ value }));
};
const old5 = Table({ rows: v1, draw: UnkeyedRow });
const next5 = Table({ rows: [newRow, ...v1], draw: UnkeyedRow });
diff(old5, next5);
report("row prepended (unkeyed)");
```

```
scenario                            component  skipped      node    attribute  patch
no memoization, one cell changed           802        0      1004         601     1
memoized, one cell changed                 404      199       208           4     1
no memoization, nothing changed            802        0      1004         601     0
row prepended (keyed)                      804        0      1004         601     1
row prepended (unkeyed)                    804        0      1004         401   401
```

#### Reading the Numbers

The first row summarizes the model. A single patch is written to the document; reaching it took 802
component calls, 1004 node comparisons, and 601 attribute comparisons — the comparisons alone run roughly
sixteen hundred to one against the patch.

This is not a flaw; it is the model's definition. The framework does not **know** what
changed; it compares to find out. The cost of comparison is proportional not to the size
of the change but to the size of the produced tree.

The third row shows this most clearly: nothing changed, yet the same 802 calls and 1004
comparisons happen — only the patch count comes out to zero. Absence of change does not
reduce the work.

The numbers hold two more components. Component calls measure rebuilding the produced
tree — each call allocates new objects and works the heap. Node comparison measures
traversing the tree. They are counted separately because they are reduced separately.

#### The Key Is Reconciliation's Input

The difference between the fourth and fifth rows shows the most concrete decision point in
this family.

When a row is prepended and there is a key, reconciliation matches every row by identity.
The 200 old rows keep the same relative order in the new list, so no node needs to move;
the single patch is the new row's insertion. (The move count here comes from a
longest-increasing-subsequence calculation: it finds the largest set of nodes that can stay
put and moves only the rest.)

Without a key, comparison goes by **position**: the new list's first item matches the old
list's first, the second matches the second, and so on. Prepending shifts every row by one
position, so no match holds — all 200 rows come out with a different name and value,
producing 401 patches. Only one row was actually inserted, yet 401 writes happen.

There is a side effect beyond the result: because nodes matched by position are reused, the
document state attached to them — focus, scroll position, an input field's typed content —
stays on the wrong row. This is why keys in lists are a correctness requirement, not a
performance preference.

#### Memoization's Trade-off

The second row shows the reduction. Once the row component is memoized, the 199 rows whose
props have not changed skip rerunning and return their previous tree. When diffing reaches
these nodes, it sees identical references and never descends: node comparison drops from
1004 to 208, attribute comparison from 601 to 4.

The gain is large but conditional: memoization only works when a prop's **identity** is
preserved. If the parent produces a new object or function on every render and passes it
as a prop, the shallow comparison fails every time and memoization gains nothing — it only
adds comparison cost. The referential-stability problem from the Derived Values lesson
becomes a performance tool in this family.

The second cost is visibility: memoization is manual — a human decides which component to
memoize and whose identity to preserve. A wrong decision produces not an error but a
silent slowdown.

#### The Family's Profile

A few points capture this family's profile and set up the comparison in the lessons ahead.

The update unit is the component; granularity stops at the component boundary. The runtime
must carry the diffing algorithm and the tree representation — a fixed slice of the
downloaded code. Because the component function is a pure transformation, the work is
divisible: the framework can split comparison into chunks and interleave other work,
since the virtual tree has no visible effect before it reaches the document. That same
purity means no ledger records where a change came from; the information is rebuilt
through comparison on every update.

#### Summary

- In this family, updating means producing a new tree from state and comparing it with the
  previous one; the re-render unit is the component.
- The cost of comparison is proportional to the size of the produced tree, not the size of
  the change: in a two-hundred-row table, a one-cell change produced 802 component calls
  and 1004 node comparisons, and a single patch was written to the document.
- The same work happens even when nothing changes; only the patch count comes out to zero.
- The key is reconciliation's identity input: in a keyed list, prepending produced a single
  patch; in an unkeyed list, it produced 401 patches, and document state on nodes matched
  by position stays on the wrong row.
- Memoization ensures subtrees whose props have not changed are never descended into; in the
  example, node comparison dropped from 1004 to 208.
- Memoization depends on the referential stability of props and is applied manually; when
  misapplied, it stays silently ineffective.

#### Next Step

The work in this family traced back to one gap: the framework did not know what changed, so
it compared to find out. Knowing would remove the need to compare. The way to know is to
record where a value is read at the moment it is read — then, when the value is written,
the list of places to update is already at hand. The next lesson covers the family that
keeps this ledger at runtime, and compares its work on the same two-hundred-row table
against the numbers here.

### Reactive Dependency Tracking

Source: https://academia.sh/en/courses/component-based-development/reactive-dependency-tracking

Fine-grained update built from dependencies recorded at read time; the amount of work the same table change produces in this family, the ledger's cost, consistency under diamond dependency, and batched writes.

The previous lesson's work traced back to one gap: the framework did not know what changed,
so it compared to find out. Knowing would remove the need to compare.

The way to know is to record where a value is read **at the moment it is read**. While a
computation runs, the values it looks at get tracked, and each one is marked "this is what
read me." When a value is written, the list of computations needing an update is already
at hand. This family turns updating from a search problem into a **notification** problem.

#### The Binding, Not the Component

In this family, the update unit is not the component. The component function runs once,
setting up the reactive values and the points where the view binds to them, then never
runs again. What gets updated is a single **binding**: a text node, an attribute, or a
class name.

Three primitives are enough. A **signal** is a readable, writable value. A **derived
value** is computed from other values and memoizes its result. An **effect** reruns when
its sources change; the code that writes to the view lives here. These are the same three
concepts from the Component Lifecycle and State topic — the difference is that here they
bind directly to each other, not to a component.

#### The Same Change, Now Counted

The previous lesson's table is rebuilt here at the same size: two hundred rows, a single
cell's value changes.

```js
// signal.mjs — dependency recorded at read time and fine-grained update
let counter = { subscription: 0, computed: 0, effect: 0, patch: 0 };
const reset = () => (counter = { subscription: 0, computed: 0, effect: 0, patch: 0 });

let tracker = null;
const queue = new Set();
const MODE = { ordered: true };   // false: the change propagates immediately, depth-first
let batchDepth = 0;
const batch = (fn) => { batchDepth++; try { fn(); } finally { if (--batchDepth === 0) flush(); } };

const track = (source) => {
  if (!tracker) return;
  source.subscribers.add(tracker);
  tracker.level = Math.max(tracker.level, source.level + 1);
  counter.subscription++;
};
const mark = (node) => {
  if (MODE.ordered) { if (!node.dirty) { node.dirty = true; queue.add(node); } }
  else { node.dirty = true; node.run(); }
};
function flush() {
  while (queue.size) {
    let next = null;
    for (const n of queue) if (!next || n.level < next.level) next = n;   // level order
    queue.delete(next);
    if (next.dirty) next.run();
  }
}

function signal(initial) {
  const d = { value: initial, subscribers: new Set(), level: 0 };
  return {
    read() { track(d); return d.value; },
    write(v) {
      if (Object.is(v, d.value)) return;
      d.value = v;
      for (const s of [...d.subscribers]) mark(s);
      if (batchDepth === 0) flush();
    },
  };
}

function derived(fn) {
  const d = { value: undefined, subscribers: new Set(), level: 0, dirty: true };
  d.run = () => {
    const previous = tracker; tracker = d;
    counter.computed++;
    const next = fn();
    tracker = previous;
    const changed = !Object.is(next, d.value);
    d.value = next; d.dirty = false;
    if (changed) for (const s of [...d.subscribers]) mark(s);
  };
  return { read() { if (d.dirty) d.run(); track(d); return d.value; } };
}

function effect(fn) {
  const d = { subscribers: new Set(), level: 0, dirty: false };
  d.run = () => { const previous = tracker; tracker = d; counter.effect++; d.dirty = false; fn(); tracker = previous; };
  d.run();
  return d;
}

// --- 1. Two-hundred-row measurement table: each cell sets up its own binding ---
const ROW_COUNT = 200;
reset();
const doc = new Array(ROW_COUNT).fill("");
const signals = Array.from({ length: ROW_COUNT }, (_, i) => signal(-10 + i * 0.1));
for (let i = 0; i < ROW_COUNT; i++) {
  effect(() => { doc[i] = `${signals[i].read().toFixed(1)} °C`; counter.patch++; });
}
const overThreshold = derived(() => signals.filter((s) => s.read() > 5).length);
let summaryText = "";
effect(() => { summaryText = `over threshold: ${overThreshold.read()}`; counter.patch++; });
const setup = { ...counter };

reset();
signals[120].write(42.5);
const update = { ...counter };

console.log("phase       subscription  derived compute  effect run  document write");
const print = (label, s) => console.log(`${label.padEnd(11)} ${String(s.subscription).padStart(8)} ${String(s.computed).padStart(17)} ${String(s.effect).padStart(15)} ${String(s.patch).padStart(14)}`);
print("setup", setup);
print("update", update);
console.log("changed cell's text:", doc[120], "| summary:", summaryText);

// --- 2. Batched writes: two writes, one update ---
reset();
signals[10].write(60);
signals[11].write(70);
console.log("\ntwo separate writes → derived compute:", counter.computed, "| effect runs:", counter.effect, "| summary:", summaryText);
reset();
batch(() => { signals[12].write(80); signals[13].write(90); });
console.log("one batched write → derived compute:", counter.computed, "| effect runs:", counter.effect, "| summary:", summaryText);

// --- 3. Diamond dependency: two values derived from the same source, plus a third that combines them ---
const log = [];
const build = () => {
  const raw = signal(-4.2);
  const celsius = derived(() => `${raw.read().toFixed(1)} °C`);
  const fahrenheit = derived(() => `${(raw.read() * 1.8 + 32).toFixed(1)} °F`);
  const summary = derived(() => `${celsius.read()} / ${fahrenheit.read()}`);
  effect(() => log.push(summary.read()));
  return raw;
};

console.log("\n--- topologically ordered propagation ---");
MODE.ordered = true; log.length = 0; reset();
build().write(10);
console.log("effect runs:", counter.effect, "| log:", JSON.stringify(log));

console.log("\n--- immediate depth-first propagation ---");
MODE.ordered = false; log.length = 0; reset();
build().write(10);
console.log("effect runs:", counter.effect, "| log:", JSON.stringify(log));
MODE.ordered = true;
```

```
phase       subscription  derived compute  effect run  document write
setup            401                 1             201            201
update           202                 1               2              2
changed cell's text: 42.5 °C | summary: over threshold: 50

two separate writes → derived compute: 2 | effect runs: 4 | summary: over threshold: 52
one batched write → derived compute: 1 | effect runs: 3 | summary: over threshold: 54

--- topologically ordered propagation ---
effect runs: 2 | log: ["-4.2 °C / 24.4 °F","10.0 °C / 50.0 °F"]

--- immediate depth-first propagation ---
effect runs: 3 | log: ["-4.2 °C / 24.4 °F","10.0 °C / 24.4 °F","10.0 °C / 50.0 °F"]
```

#### The Two Families' Numbers

Set the update row next to the previous lesson's and the contrast is sharp. The
virtual-tree family's one-cell change produced 802 component calls, 1004 node
comparisons, and 601 attribute comparisons. Here there is one derived recompute, two effect
runs, and two document writes — the tree is never traversed, no comparison happens at all.

One write is the changed cell, the other the threshold summary — a derived value bound to
all two hundred signals, so it recomputes no matter which cell changed. **Aggregating
computations fall outside fine-grained granularity**: a value that depends on everything
runs on every change. The family's gain comes from the locality of bindings, not from a
change in the data flow's structure.

The gap widens with scale. In the virtual-tree family, the work is proportional to the row
count; here it is proportional to the number of changed bindings. In a two-thousand-row
table, the first number grows tenfold; the second stays the same.

#### The Ledger's Cost

The setup row shows the cost: 401 subscriptions, 201 effect runs — one effect per cell,
one subscription per effect, plus the summary computation subscribing to all two hundred
signals at once.

This is a graph kept in memory: every signal carries a set of subscribers, every
computation a list of sources. The virtual-tree family has no such structure — it keeps
only the previous tree, and relationships are rederived on every comparison. Here the
relationships persist and occupy memory.

The 202 subscriptions on the update row show a second cost: every time a computation runs,
it records its dependencies **again**, since a conditional read might bind to a different
source next time. The summary computation rereads all two hundred signals and
resubscribes. Maintaining the ledger is itself a line item of work.

The third cost is invisible connection: where a value is read is not marked in the source
text, the read itself does the recording. Placing a read in the wrong spot — moving it
outside an effect, for instance — silently severs the binding, and the view does not
update. Like the call-order rule for position-based hooks, this is a contract the language
does not enforce.

#### Diamond Dependency and Consistency

The last two sections show a notification-based system's most subtle problem.

Two values are derived from a raw measurement — Celsius and Fahrenheit — and a third
combines them. Writing the raw value invalidates both derived values, but they update in
sequence. Under immediate depth-first propagation, the summary updates the instant Celsius
does, while Fahrenheit is still at its old value.

The second line of the log shows this: `10.0 °C / 24.4 °F`. This value was never real —
Celsius is new, Fahrenheit is old. This is called a **glitch**, and it does not just run the
effect one extra time; that extra run produces a wrong picture.

This does not happen under topologically ordered propagation: every computation gets a
level one higher than the highest level among its sources, and the update queue drains in
level order, so a computation runs only after all its sources have updated. The log shows
only the starting value and the consistent final value.

This looks like an implementation detail of the family, but it carries correctness
directly. One of the questions to ask when evaluating a framework's reactive core is
whether glitches are prevented by definition.

#### Batched Writes

The second section shows a second consistency tool. Writing two signals separately runs
the summary computation twice and fires effects four times; the same two writes inside a
single batch compute the summary once and drop the effect count to three.

The gain is not just the amount of work: batching makes two changes appear as **a single
pass**, so the in-between state that was never valid never leaks out. This is the same
batching concept from the Component Lifecycle and State topic — here it is tied not to the
component boundary but to an explicitly marked block.

#### The Family's Profile

The update unit is the binding; granularity descends all the way to a single text node.
The amount of work is proportional not to the size of the produced tree but to the number
of changed bindings, and memoization is usually unnecessary — a derived value is already
memoized.

In exchange, the runtime must build and maintain the dependency graph: setup costs more,
memory use grows with the number of bindings, and records refresh every time a computation
runs. A component function that runs once demands a different mental model than the
"start over on every render" habit — a value read during setup freezes, and must be read
reactively to stay tracked.

#### Summary

- In this family, dependency is recorded at read time; the update unit is not the
  component but a single binding.
- The same one-cell change produced 802 component calls and 1004 node comparisons in the
  virtual-tree family, versus one derived recompute and two document writes here.
- Aggregating computations fall outside fine-grained granularity: a derived value bound to
  every signal recomputes no matter which signal changed.
- The ledger is kept in memory and needs maintenance; every computation re-records its
  dependencies each time it runs.
- Immediate depth-first propagation produces a glitch under diamond dependency;
  topologically ordered propagation prevents it by definition.
- Batching both reduces the amount of work and keeps intermediate state from leaking out.

#### Next Step

Both families keep their ledger at runtime: one by comparing trees, the other by recording
subscriptions. Both have a counterpart in the downloaded code — a diffing algorithm or a
dependency-graph mechanism — that has to reach the browser the moment the page loads. Yet
which expression depends on which value can, in most cases, be read straight from the
source text: in a template, which variable a text node reads is written down. The next
lesson covers the approach that draws this inference before the code runs, writing a
transformer that resolves dependencies at compile time and leaves only update code for the
runtime.

### Compiler-Based Approaches

Source: https://academia.sh/en/courses/component-based-development/compiler-based-approaches

Resolving dependencies at compile time; a small transformer that turns a template into update code, reading the generated code, the soundness limit of analysis, and the cost of the compile step.

The previous two families both kept their ledger at runtime: one by comparing trees, the
other by recording subscriptions. Both had a counterpart in the downloaded code.

Yet which expression depends on which value can, in most cases, be read straight from the
source text: in a template, which variable a text node reads is written down. A tool that
extracts this before the code runs can leave the runtime nothing but direct code —
"if this value changes, write this spot."

#### Dependency Is Written in the Source Text

The chain built in the Compilation Stages lesson from the How Computers Work course
repeats here at small scale: lexical analysis, tree building, analysis, and code
generation. The input is a template, the output is executable update code.

The difference is this: in the previous two families, the framework interprets the
application's **data**; in this family, the tool interprets the application's **source
text**. Interpretation happens once, at compile time, and its result never changes again.

The transformer below builds this in its plainest form: it splits the template into
static parts and expression slots, finds the state fields each expression reads, and
produces, per field, a function that writes only the slots that field affects.

```js
// compiler.mjs — resolving the template at compile time into a transformer that generates update code
const STATE_FIELDS = ["name", "value", "timestamp", "thresholdExceeded"];

const TEMPLATE = `<tr class="row {thresholdExceeded ? 'warning' : 'normal'}">
  <th scope="row">{name}</th>
  <td class="value">{value.toFixed(1)} °C</td>
  <td class="timestamp">{thresholdExceeded ? timestamp : '—'}</td>
</tr>`;

// 1) Parsing: the template is split into static parts and expression slots.
function parse(template) {
  const staticParts = [];
  const expressions = [];
  let last = 0;
  for (const match of template.matchAll(/\{([^{}]*)\}/g)) {
    staticParts.push(template.slice(last, match.index));
    expressions.push(match[1].trim());
    last = match.index + match[0].length;
  }
  staticParts.push(template.slice(last));
  return { staticParts, expressions };
}

// No analysis can be correct without first stripping out string literals.
const stripStrings = (expr) => expr.replace(/'[^']*'|"[^"]*"/g, (d) => " ".repeat(d.length));

// 2) Analysis: find the state fields each expression reads.
function analyze(expr) {
  const g = stripStrings(expr);
  const dynamic = /\[[^\]]*\]/.test(g);                    // access like state[variable]
  const names = STATE_FIELDS.filter((a) => new RegExp(`(?<![.\\w$])${a}\\b`).test(g));
  return { names, dynamic };
}

// 3) Code generation: state names in the expression are qualified with `d.`.
function qualify(expr) {
  const g = stripStrings(expr);
  let result = "";
  let last = 0;
  const matches = [];
  for (const a of STATE_FIELDS)
    for (const e of g.matchAll(new RegExp(`(?<![.\\w$])${a}\\b`, "g"))) matches.push({ index: e.index, length: a.length });
  matches.sort((x, y) => x.index - y.index);
  for (const e of matches) {
    result += expr.slice(last, e.index) + "d." + expr.slice(e.index, e.index + e.length);
    last = e.index + e.length;
  }
  return result + expr.slice(last);
}

function compile(template) {
  const { staticParts, expressions } = parse(template);
  const resolved = expressions.map(analyze);
  const bodies = expressions.map((expr, i) => `y[${i}] = String(${qualify(expr)});`);
  const affected = {};
  for (const field of STATE_FIELDS)
    affected[field] = resolved.map((c, i) => (c.dynamic || c.names.includes(field) ? i : -1)).filter((i) => i >= 0);

  const source = [
    `const STATIC = ${JSON.stringify(staticParts)};`,
    `const slotWriters = [`,
    ...bodies.map((g, i) => `  (d, y) => { ${g} },   // slot ${i}`),
    `];`,
    `const setup = (d) => { const y = []; for (const f of slotWriters) f(d, y); return y; };`,
    ...STATE_FIELDS.map((a) =>
      `const ${a}Changed = (d, y) => { ${affected[a].map((i) => `slotWriters[${i}](d, y);`).join(" ")} return ${affected[a].length}; };`),
    `const render = (y) => STATIC.reduce((m, s, i) => m + s + (y[i] ?? ""), "");`,
  ].join("\n");

  // Turn the generated code into something runnable.
  const slotWriters = bodies.map((g) => new Function("d", "y", g));
  const module = {
    setup: (d) => { const y = []; for (const f of slotWriters) f(d, y); return y; },
    render: (y) => staticParts.reduce((m, s, i) => m + s + (y[i] ?? ""), ""),
  };
  for (const a of STATE_FIELDS)
    module[`${a}Changed`] = (d, y) => { for (const i of affected[a]) slotWriters[i](d, y); return affected[a].length; };
  return { source, staticParts, expressions, resolved, affected, module };
}

const compiled = compile(TEMPLATE);
console.log("--- generated code ---");
console.log(compiled.source);

console.log("\n--- expression / dependency table ---");
compiled.expressions.forEach((expr, i) =>
  console.log(`  slot ${i}: ${expr.padEnd(41)} → ${JSON.stringify(compiled.resolved[i].names)}`));
console.log("  field → affected slots:", JSON.stringify(compiled.affected));

const m = compiled.module;
const state = { name: "sensor-120", value: 42.5, timestamp: "06:00", thresholdExceeded: true };
const slots = m.setup(state);
console.log("\nfirst render:");
console.log(m.render(slots));

state.value = 43.1;
console.log(`\nvalue changed → expressions re-evaluated: ${m.valueChanged(state, slots)}`);
console.log(m.render(slots));

state.thresholdExceeded = false;
console.log(`\nthreshold state changed → expressions re-evaluated: ${m.thresholdExceededChanged(state, slots)}`);
console.log(m.render(slots));

console.log("\nchanged field           compiled  runtime without analysis");
for (const field of STATE_FIELDS)
  console.log(`${field.padEnd(23)} ${String(compiled.affected[field].length).padStart(9)} ${String(compiled.expressions.length).padStart(34)}`);

// 4) The limit of analysis: dynamic access produces a dependency on all fields.
const ambiguous = compile(`<td>{value[name]}</td>`);
console.log("\ntemplate with dynamic access:");
console.log("  expression:", ambiguous.expressions[0], "| dynamic:", ambiguous.resolved[0].dynamic);
console.log("  field → affected slots:", JSON.stringify(ambiguous.affected));
```

```
--- generated code ---
const STATIC = ["<tr class=\"row ","\">\n  <th scope=\"row\">","</th>\n  <td class=\"value\">"," °C</td>\n  <td class=\"timestamp\">","</td>\n</tr>"];
const slotWriters = [
  (d, y) => { y[0] = String(d.thresholdExceeded ? 'warning' : 'normal'); },   // slot 0
  (d, y) => { y[1] = String(d.name); },   // slot 1
  (d, y) => { y[2] = String(d.value.toFixed(1)); },   // slot 2
  (d, y) => { y[3] = String(d.thresholdExceeded ? d.timestamp : '—'); },   // slot 3
];
const setup = (d) => { const y = []; for (const f of slotWriters) f(d, y); return y; };
const nameChanged = (d, y) => { slotWriters[1](d, y); return 1; };
const valueChanged = (d, y) => { slotWriters[2](d, y); return 1; };
const timestampChanged = (d, y) => { slotWriters[3](d, y); return 1; };
const thresholdExceededChanged = (d, y) => { slotWriters[0](d, y); slotWriters[3](d, y); return 2; };
const render = (y) => STATIC.reduce((m, s, i) => m + s + (y[i] ?? ""), "");

--- expression / dependency table ---
  slot 0: thresholdExceeded ? 'warning' : 'normal'  → ["thresholdExceeded"]
  slot 1: name                                      → ["name"]
  slot 2: value.toFixed(1)                          → ["value"]
  slot 3: thresholdExceeded ? timestamp : '—'       → ["timestamp","thresholdExceeded"]
  field → affected slots: {"name":[1],"value":[2],"timestamp":[3],"thresholdExceeded":[0,3]}

first render:
<tr class="row warning">
  <th scope="row">sensor-120</th>
  <td class="value">42.5 °C</td>
  <td class="timestamp">06:00</td>
</tr>

value changed → expressions re-evaluated: 1
<tr class="row warning">
  <th scope="row">sensor-120</th>
  <td class="value">43.1 °C</td>
  <td class="timestamp">06:00</td>
</tr>

threshold state changed → expressions re-evaluated: 2
<tr class="row normal">
  <th scope="row">sensor-120</th>
  <td class="value">43.1 °C</td>
  <td class="timestamp">—</td>
</tr>

changed field           compiled  runtime without analysis
name                            1                                  4
value                           1                                  4
timestamp                       1                                  4
thresholdExceeded               2                                  4

template with dynamic access:
  expression: value[name] | dynamic: true
  field → affected slots: {"name":[0],"value":[0],"timestamp":[0],"thresholdExceeded":[0]}
```

#### Reading the Generated Code

The first section of the output shows exactly what reaches the runtime: four slot-writer
functions, one update function per field, and a text combiner — no diffing algorithm, no
subscriber set, no tree representation. The code says **directly** which spot gets
written on which change.

`thresholdExceededChanged` writes two slots at once, because the threshold state is read
both in the row's class name and in the timestamp cell. This information comes from the
dependency table and rests on no computation at runtime.

`timestampChanged` writes only the fourth slot. When the timestamp changes, the row's class
name is not recomputed — because the class name does not read the timestamp. A runtime
without analysis would re-evaluate all four expressions no matter which field changed; the
table shows this difference.

Separating out the static parts is a second gain: the `<th scope="row">` text is produced
once and never touched by any update again. In the virtual-tree family this node is
traversed on every comparison; here there is no comparison step at all.

#### The Soundness Limit of Analysis

The last section shows the approach's breaking point: in the expression `value[name]`,
the field being read is not apparent from the source text — which key gets used is only
known at runtime. The analyzer must act **conservatively**: it treats the expression as
dependent on every field, so the slot gets rewritten no matter which field changes.

This is the compiler-based approach's general rule. Inference must be **sound**: missing a
dependency means a view that does not update, a correctness problem, not a performance one.
An extra dependency only produces unnecessary work — faced with ambiguity, the tool always
chooses the extra.

The result is a move toward a subset of the language: template languages deliberately
restrict expression syntax — limited expression forms, limited access paths, explicitly
declared state. The restriction is not a deficiency; it is the condition for inference to
work at all. The same idea appears in the preprocessors from the Layout Systems and
Responsive Design course — the narrower the input language, the more the tool can tell
you.

Analysis also sees **unused** paths: if a state field is never read, the update function
generated for it stays empty, and unused components and branches can be dropped from the
bundle entirely. The dead code elimination from the Compilation Stages lesson moves here
to the application level.

#### The Cost of the Compile Step

The price of these gains is that the code that runs is not the code that was written.

Debugging is the first consequence: the line where a breakpoint is set and the line that
actually runs are different, and a source map builds the bridge between them. The source
map introduced in the Source Debugging lesson from The Browser and the Web Platform course
is a required part of this family, not an optional convenience — if the mapping is missing
or broken, the error message points at an incomprehensible location.

The second cost is a toolchain dependency: the code does not run directly in the browser,
it has to pass through a compile step. This step needs to be fast during development, or
every change means waiting. Version upgrades must also preserve compatibility between
compiler and source text — the tool's version becomes part of the application's source
text.

The third cost is predictability. Even when the generated code is readable, what the
written code will produce is not always obvious: moving an expression into a helper
function can keep the analyzer from seeing the dependency and drop it into conservative
mode, increasing work with no error raised. Diagnosing performance problems in this family
requires reading the generated code.

#### The Family's Profile

The update unit is a single write, determined at compile time. The runtime code is
proportional not to the application's complexity but to the features actually used; no
general-purpose update machinery is carried along.

In exchange comes a compile step, a source-map chain, and a restriction on the input
language. Because analysis must stay sound, dynamic constructs produce conservative
dependencies — a direct trade-off between the language's expressive power and the
sharpness of the inference.

The boundary between this family and the previous two is not sharp. Frameworks that track
dependencies at runtime also compile their templates; virtual-tree-holding frameworks can
also mark static subtrees at compile time and exclude them from comparison. The
distinction is a matter of **where** the weight of the decision falls.

#### Summary

- In this family, which expression depends on which value is extracted from the source
  text before the code runs, and the result is generated directly as update code.
- The generated code has no diffing algorithm, no subscriber set, and no tree
  representation; which slots get written per field is fixed.
- Static parts are produced once and never traversed by any update.
- Inference must be sound: in ambiguous cases like dynamic access, the tool acts
  conservatively and produces a dependency on every field.
- A restricted input language is the condition for sharp inference; dropping unused paths
  from the bundle comes from the same analysis.
- The cost is that the code that runs is not the code that was written: the source map
  becomes a required part, and the tool's version becomes a dependency of the source text.

#### Next Step

All three families answered the same question: which part of the document gets written
when state changes. Above the component layer sit other questions — which view an address
maps to, where a form gets its validation rules, how a service reaches a component. In the
families covered so far, the answers lie outside the framework, and each team builds its
own combination. Another approach supplies these answers as part of the framework itself.
The next lesson covers frameworks that gather routing, the form model, and dependency
resolution into a single contract, and shows by counting how integration shrinks the
decision surface — and what it binds in exchange.

### Full-Stack Frameworks

Source: https://academia.sh/en/courses/component-based-development/full-stack-frameworks

Frameworks that gather routing, the form model, and dependency resolution into a single contract; how scoped dependency resolution works, the singleton trap, counting the decision surface, and the cost of contract unity.

All three previous families answered the same question: which part of the document gets
written when state changes. Above the component layer sit other questions — which view an
address maps to, where the filter panel gets its validation rules, how the measurement
service reaches a component — and as the North Slope Measurement Station page grows, all
of them need deciding.

In the first three families, these decisions lie outside the framework; each team builds
its own combination. The fourth approach supplies the answers as part of the framework
itself.

#### The Roles the Framework Covers

A **full-stack framework** offers routing, the form model, data fetching, dependency
resolution, server rendering, and testing tools alongside the view layer, all under one
contract. The parts are not chosen separately — they arrive together, version together,
and are designed to recognize each other.

This family's most distinctive part is **dependency injection**. A component does not
build the service it needs; it asks for it by key, and the framework finds the registered
provider and hands back an instance. The interface/implementation distinction from the
Programming Fundamentals course gets carried into the runtime here.

#### Scoped Dependency Resolution

The mechanism has three parts: provider registration, the scope chain, and the rule that
determines where instances live.

```js
// injection.mjs — scoped dependency resolution: registration, scope chain, and overriding
const openScope = (name, parent = null) => ({ name, parent, providers: new Map(), instances: new Map() });
const register = (scope, key, create, { perScope = false } = {}) =>
  scope.providers.set(key, { create, perScope });

const trace = [];
function resolve(scope, key, chain = []) {
  if (chain.includes(key)) throw new Error(`circular dependency: ${[...chain, key].join(" → ")}`);
  let owner = scope;
  while (owner && !owner.providers.has(key)) owner = owner.parent;
  if (!owner) throw new Error(`no registered provider: ${key} (requested by: ${scope.name})`);
  const entry = owner.providers.get(key);
  const home = entry.perScope ? scope : owner;      // where the instance will live
  if (home.instances.has(key)) {
    trace.push(`${scope.name}: ${key} ← ${home.name} (existing instance)`);
    return home.instances.get(key);
  }
  trace.push(`${scope.name}: ${key} ← ${owner.name} (new instance, ${entry.perScope ? "per scope" : "singleton"})`);
  const instance = entry.create((a) => resolve(scope, a, [...chain, key]));
  home.instances.set(key, instance);
  return instance;
}

// --- Application registration ---
const root = openScope("root");
let counter = 0;
register(root, "MeasurementSource", () => ({ id: ++counter, fetch: () => [{ name: "Upper Slope", value: -4.2 }] }));
register(root, "UnitFormatter", () => ({ format: (c) => `${c.toFixed(1)} °C` }));
register(root, "MeasurementService", (c) => {
  const source = c("MeasurementSource"), formatter = c("UnitFormatter");
  return { id: ++counter, rows: () => source.fetch().map((o) => `${o.name}: ${formatter.format(o.value)}`) };
});
register(root, "FilterState", () => ({ id: ++counter, threshold: -5 }), { perScope: true });

const tableScope = openScope("measurement table", root);
const panelScope = openScope("filter panel", root);

console.log("--- resolution trace ---");
const t = resolve(tableScope, "MeasurementService");
const p = resolve(panelScope, "MeasurementService");
const ts = resolve(tableScope, "FilterState");
const ps = resolve(panelScope, "FilterState");
for (const s of trace) console.log(" ", s);
console.log("\nsame measurement service instance:", t === p);
console.log("same filter state instance:", ts === ps, `(ids: ${ts.id}, ${ps.id})`);
console.log("table's rows:", JSON.stringify(t.rows()));

// --- Overriding for testing: (a) under the existing root, (b) with a fresh root ---
const childScope = openScope("test (under root)", root);
register(childScope, "MeasurementSource", () => ({ id: 0, fetch: () => [{ name: "Fake", value: 0 }] }));
trace.length = 0;
const childService = resolve(childScope, "MeasurementService");
console.log("\n--- (a) overriding under the existing root ---");
for (const s of trace) console.log(" ", s);
console.log("rows:", JSON.stringify(childService.rows()), "| same instance as root's:", childService === t);

const freshRoot = openScope("fresh root");
for (const [key, entry] of root.providers) freshRoot.providers.set(key, entry);
register(freshRoot, "MeasurementSource", () => ({ id: 0, fetch: () => [{ name: "Fake", value: 0 }] }));
trace.length = 0;
const freshService = resolve(openScope("test (fresh root)", freshRoot), "MeasurementService");
console.log("\n--- (b) overriding with a fresh root ---");
for (const s of trace) console.log(" ", s);
console.log("rows:", JSON.stringify(freshService.rows()), "| same instance as root's:", freshService === t);

// --- Errors are reported explicitly ---
console.log("\n--- errors ---");
register(root, "A", (c) => ({ b: c("B") }));
register(root, "B", (c) => ({ a: c("A") }));
for (const attempt of [() => resolve(tableScope, "Log"), () => resolve(root, "A")]) {
  try { attempt(); } catch (e) { console.log(" ", e.message); }
}
```

```
--- resolution trace ---
  measurement table: MeasurementService ← root (new instance, singleton)
  measurement table: MeasurementSource ← root (new instance, singleton)
  measurement table: UnitFormatter ← root (new instance, singleton)
  filter panel: MeasurementService ← root (existing instance)
  measurement table: FilterState ← root (new instance, per scope)
  filter panel: FilterState ← root (new instance, per scope)

same measurement service instance: true
same filter state instance: false (ids: 3, 4)
table's rows: ["Upper Slope: -4.2 °C"]

--- (a) overriding under the existing root ---
  test (under root): MeasurementService ← root (existing instance)
rows: ["Upper Slope: -4.2 °C"] | same instance as root's: true

--- (b) overriding with a fresh root ---
  test (fresh root): MeasurementService ← fresh root (new instance, singleton)
  test (fresh root): MeasurementSource ← fresh root (new instance, singleton)
  test (fresh root): UnitFormatter ← fresh root (new instance, singleton)
rows: ["Fake: 0.0 °C"] | same instance as root's: false

--- errors ---
  no registered provider: Log (requested by: measurement table)
  circular dependency: A → B → A
```

The first three trace lines show resolution descending: the measurement service's
provider is found in the root scope, and while the service is built, its own dependencies
resolve the same way. The component does not know how any of them are built — it only
knows the keys.

The fourth line shows singleton behavior: the filter panel requests the same service and
gets the root scope's existing instance, not a new one. In the fifth and sixth lines,
because the provider is registered per scope, the two scopes each get their own
instance — the registration lives at the root, the instance lives in the requesting scope.

This distinction is a design decision with direct correctness consequences. The
measurement source should be shared — two components opening separate connections would
be pointless. Filter state should not be shared — if the table's filter were the panel's,
the two components would overwrite each other's state.

#### The Singleton Trap

Sections (a) and (b) show a trap common to every scoped resolution mechanism.

In the test scope, the measurement source is overridden with a fake, but the result
does not change. The trace explains why: the measurement service was already built in the
root scope, and its instance sits there. The new scope's request returns that existing
instance, which took the real source at build time and never asks again.

Section (b) shows the correct path: a fresh root is built for testing, the provider
registrations are copied over, and the overridden key is re-registered there. With no
instance built yet, resolution happens from scratch, and the fake source enters the
chain.

The rule is this: **overriding has to happen before the instance is built.** Overriding a
dependency in a singleton scope requires rebuilding that entire scope.

Error messages are also a feature of this family: an unregistered key reports which scope
asked for what, and a circular dependency reports the entire chain. As indirection grows,
the error has to explain itself.

#### The Decision Surface

Integration's gain is not in individual features but in the number of decisions. This can
be counted.

```js
// decision-surface.mjs — integration's effect on decision and compatibility surface
const ROLES = [
  "view layer", "routing", "form model", "data fetching",
  "dependency resolution", "shared state", "server rendering", "testing tools",
];

// A profile: which package covers each role.
const profiles = {
  "library assembly": ROLES.map((r) => r),                       // every role is its own package
  "core + plugin": ROLES.map((r, i) => (i < 3 ? "core" : r)),
  "batteries-included": ROLES.map(() => "framework"),
};

const pairwise = (m) => (m * (m - 1)) / 2;

console.log("profile                  independent       version        pairwise          reviewed on  roles changed");
console.log("                             package        stream          compat      breaking change        at once");
for (const [name, assignment] of Object.entries(profiles)) {
  const packages = [...new Set(assignment)];
  const m = packages.length;
  // If the package carrying the most roles is dropped, how many roles change at once?
  const largest = Math.max(...packages.map((p) => assignment.filter((a) => a === p).length));
  console.log(
    `${name.padEnd(20)} ${String(m).padStart(15)} ${String(m).padStart(13)} ${String(pairwise(m)).padStart(15)} ` +
    `${String(Math.max(m - 1, 0)).padStart(20)} ${String(largest).padStart(14)}`
  );
}

// Same calculation as role count changes: the compatibility surface grows quadratically.
console.log("\nseparate packages  pairwise compat relationships");
for (const m of [1, 2, 4, 6, 8, 12]) console.log(`${String(m).padStart(17)} ${String(pairwise(m)).padStart(24)}`);

// How removable a role is from the framework: how tightly the framework binds that role.
const COUPLING = { "no contract": 0, "via interface": 1, "direct type dependency": 2, "code generation": 3 };
const sample = [
  ["routing", "code generation"],
  ["form model", "direct type dependency"],
  ["data fetching", "via interface"],
  ["shared state", "no contract"],
];
console.log("\nrole               coupling form              exit score");
let total = 0;
for (const [role, form] of sample) {
  total += COUPLING[form];
  console.log(`${role.padEnd(18)} ${form.padEnd(26)} ${String(COUPLING[form]).padStart(17)}`);
}
console.log(`total exit cost (0 = free, ${sample.length * 3} = fully bound): ${total}`);
```

```
profile                  independent       version        pairwise          reviewed on  roles changed
                             package        stream          compat      breaking change        at once
library assembly                   8             8              28                    7              1
core + plugin                      6             6              15                    5              3
batteries-included                 1             1               0                    0              8

separate packages  pairwise compat relationships
                1                        0
                2                        1
                4                        6
                6                       15
                8                       28
               12                       66

role               coupling form              exit score
routing            code generation                            3
form model         direct type dependency                     2
data fetching      via interface                              1
shared state       no contract                                0
total exit cost (0 = free, 12 = fully bound): 6
```

Covering eight roles with separate packages means eight decisions, eight independent
version streams, and 28 pairwise compatibility relationships. The relationship count
grows with the square of the package count — 66 at twelve packages. Each relationship is a
claim that two packages' specific versions work together, and the team has to verify that
claim.

In the batteries-included profile, this number is zero: compatibility among the parts is
the framework's responsibility, and the team follows a single version stream. A new team
member learns one contract, not the combination of eight libraries.

The last column shows the cost. In library assembly, dropping one package changes only one
role, and roles can migrate separately, at separate times. In the batteries-included
profile, dropping the framework changes eight roles at once — the decision stops being
reversible.

#### The Cost of Contract Unity

The last table shows how to measure this cost: how removable a role is depends on how the
framework binds it. A role bound only through an interface can be replaced by another
implementation satisfying that interface; a role bound through code generation leans on
the framework down to the shape of the source text.

Measuring the application's exit cost turns the framework choice from a preference into
an investment decision. The measure is which parts can be written independently of the
framework: validation rules, formatting, the state machines inside headless components,
and pure presentational components do not know the framework and carry over as-is in a
migration.

The second cost is version management: a single version stream means a framework upgrade
touches **every** layer at once. A breaking change can span routing, the form, and
dependency resolution together, and the upgrade cannot be split into small steps. Separate
packages let upgrades be sequenced, but in exchange the team carries 28 compatibility
relationships.

The third cost is flexibility: if the framework's answer for a role does not fit the
application's needs, changing it means working against the framework's assumptions. In an
integrated framework, stepping outside the beaten path costs markedly more than staying on
it.

#### The Family's Profile

This family is not an update model; it is a **scope** decision — it can contain a virtual
tree, runtime dependency tracking, or compile-time analysis inside it. Its distinguishing
feature is that the roles above the component layer are part of the framework.

The gain is fewer decisions and compatibility relationships, a single contract shared
across the team, and a narrower surface for a new member to learn. The cost is a single
version stream, upgrades that cannot be split, and an exit cost paid all at once.

#### Summary

- A full-stack framework gathers roles like routing, forms, data fetching, dependency
  resolution, and testing into a single contract, alongside the view layer.
- In dependency injection, a component does not build a service, it asks for one by key;
  the scope chain finds the provider, and the registration form determines where the
  instance lives.
- Overriding a singleton-registered dependency has no effect once the instance is built;
  for testing, the scope is built from scratch.
- The decision surface is countable: eight roles in separate packages produce eight
  version streams and 28 pairwise compatibility relationships; in an integrated framework,
  this number drops to zero.
- In exchange, the exit cost is paid all at once: dropping the framework changes eight
  roles simultaneously.
- How removable a role is depends on its coupling form; a role bound through an interface
  is replaceable, a role bound through code generation leans on the framework down to the
  source text.

#### Next Step

All four families now have their characteristics, costs, and trade-offs laid out. One
question remains, and its answer lies not in the families but in the project itself: which
features does this application actually need? The next lesson builds not a comparison
table but a decision framework: it ties criteria like team size and experience,
interaction density, the rendering strategy requirement, ecosystem depth, and maintenance
cost to explicit weights, computes which criterion has to flip the decision, and measures
how much of the decision stays reversible.

### Framework Selection

Source: https://academia.sh/en/courses/component-based-development/framework-selection

Where criteria come from, writing weights explicitly, testing the ranking with sensitivity analysis, measuring the decision's reversibility at the line level, and putting the decision in writing.

All four families now have their characteristics, costs, and trade-offs laid out. One
question remains, and its answer lies not in the families' characteristics but in the
project itself.

This lesson is not a comparison table — such a table is incomplete even for the month
it is written in, and misleading a year later. What is built instead is a framework that
writes criteria explicitly, makes weights visible, and states which assumption the
decision rests on. The framework does not change; its inputs get refilled for every
project.

#### Where Criteria Come From

Most selection debates get stuck because different kinds of information end up on the
same list. Criteria come from three sources, and each has a different reliability.

**Criteria derivable from the family** were measured across this topic's four lessons:
update granularity, the code reaching the runtime, the compile-step requirement, manual
memoization overhead, fit with dynamic structures, update divisibility, and
decision-surface width. These follow from the family's definition, known without looking
at any product's documentation.

**Criteria read from the candidate** are measured by looking at a specific framework:
documentation quality, breaking-change history, release cadence, issue-closing time,
accessibility support. These do not follow from the family and change over time — they
must be measured at decision time.

**Criteria read from the team** belong to neither the family nor the candidate: prior
experience, learning budget, ease of hiring, expected project lifespan, who will maintain
it. These often weigh heaviest and get discussed least in technical debate.

The model below scores only the first group's criteria; the second and third groups are
treated not as weights but as independent inputs that feed directly into the decision.

#### Writing Weights Explicitly

```js
// selection.mjs — decision framework: explicit weights and sensitivity analysis
// Scores (0-3) come from characteristics measured in previous lessons; this is not a product evaluation.

const UPDATE_MODEL = {
  "virtual-tree-based": {
    "fine-grained update": 1, "little runtime code": 1, "no compile step required": 3,
    "no manual memoization required": 0, "fits dynamic structures": 3, "update divisibility": 3,
  },
  "runtime dependency tracking": {
    "fine-grained update": 3, "little runtime code": 2, "no compile step required": 2,
    "no manual memoization required": 3, "fits dynamic structures": 3, "update divisibility": 1,
  },
  "compile-time reactive": {
    "fine-grained update": 3, "little runtime code": 3, "no compile step required": 0,
    "no manual memoization required": 3, "fits dynamic structures": 1, "update divisibility": 1,
  },
};

const SCOPE = {
  "narrow-scoped": { "small decision surface": 0, "upgrade divisibility": 3, "gradual exit": 3, "single contract": 1 },
  "integrated": { "small decision surface": 3, "upgrade divisibility": 0, "gradual exit": 0, "single contract": 3 },
};

const PROJECTS = {
  "public station page": {
    "fine-grained update": 1, "little runtime code": 5, "no compile step required": 1,
    "no manual memoization required": 2, "fits dynamic structures": 1, "update divisibility": 1,
    "small decision surface": 2, "upgrade divisibility": 2, "gradual exit": 3, "single contract": 1,
  },
  "internal observability dashboard": {
    "fine-grained update": 5, "little runtime code": 1, "no compile step required": 1,
    "no manual memoization required": 3, "fits dynamic structures": 3, "update divisibility": 2,
    "small decision surface": 4, "upgrade divisibility": 1, "gradual exit": 1, "single contract": 4,
  },
};

const score = (candidates, weight) =>
  Object.entries(candidates).map(([name, traits]) => [
    name,
    Object.entries(traits).reduce((t, [criterion, p]) => t + (weight[criterion] ?? 0) * p, 0),
  ]).sort((a, b) => b[1] - a[1]);

for (const [project, weight] of Object.entries(PROJECTS)) {
  console.log(`\n=== ${project} ===`);
  for (const [title, candidates] of [["update model", UPDATE_MODEL], ["scope", SCOPE]]) {
    const ranking = score(candidates, weight);
    console.log(`  ${title}:`);
    for (const [name, p] of ranking) console.log(`    ${name.padEnd(38)} ${String(p).padStart(4)}`);
    console.log(`    gap (first − second): ${ranking[0][1] - ranking[1][1]}`);
  }
}

// Sensitivity: at what weight for one criterion does the top of the ranking flip?
console.log("\n=== sensitivity: update model, internal observability dashboard ===");
const baseline = { ...PROJECTS["internal observability dashboard"] };
const winner = (a) => score(UPDATE_MODEL, a)[0][0];
const baselineWinner = winner(baseline);
console.log(`top rank at baseline weights: ${baselineWinner}`);
console.log("criterion                            baseline  flip weight  new top rank");
for (const criterion of Object.keys(UPDATE_MODEL["virtual-tree-based"])) {
  let flip = null, next = null;
  for (let w = 0; w <= 20; w++) {
    const trial = { ...baseline, [criterion]: w };
    if (winner(trial) !== baselineWinner) { flip = w; next = winner(trial); break; }
  }
  console.log(
    `${criterion.padEnd(36)} ${String(baseline[criterion]).padStart(5)} ` +
    `${(flip === null ? "no flip" : String(flip)).padStart(15)}  ${next ?? "-"}`
  );
}
```

```

=== public station page ===
  update model:
    compile-time reactive                    26
    runtime dependency tracking              25
    virtual-tree-based                       15
    gap (first − second): 1
  scope:
    narrow-scoped                            16
    integrated                                9
    gap (first − second): 7

=== internal observability dashboard ===
  update model:
    runtime dependency tracking              39
    compile-time reactive                    32
    virtual-tree-based                       24
    gap (first − second): 7
  scope:
    integrated                               24
    narrow-scoped                            10
    gap (first − second): 14

=== sensitivity: update model, internal observability dashboard ===
top rank at baseline weights: runtime dependency tracking
criterion                            baseline  flip weight  new top rank
fine-grained update                      5         no flip  -
little runtime code                      1               9  compile-time reactive
no compile step required                 1              16  virtual-tree-based
no manual memoization required           3         no flip  -
fits dynamic structures                  3         no flip  -
update divisibility                      2              10  virtual-tree-based
```

The same score table produces different rankings for the two projects — the difference
comes from the weights, not the families: on the public page, downloaded-code leanness
carries five times the weight; on the observability dashboard, fine-grained update does.

On the scope axis, the ranking splits more sharply: long lifespan and gradual exit matter
most on the public page, so narrow scope leads; a small decision surface and a single
contract dominate on the internal dashboard, so the integrated profile leads. The two axes
are independent and decided separately.

#### What Matters Is the Gap, Not the Ranking

In the first project, the gap between first and second place is **1 point** — the model
gives no real answer for that project. The scores rest on estimated weights, and a
one-point gap disappears with the smallest change to them. The correct reading is not
"first place won" but "**these two options are not separated by these criteria**." The
decision is left to criteria outside the model — those read from the candidate and the
team.

In the second project, the gap is 7 points, and the sensitivity table shows how solid
that is: raising the heaviest criterion's weight to twenty does not change first place, but
raising little-runtime-code's weight from 1 to 9 flips the ranking. This states which
assumption the decision rests on — if "downloaded code is secondary for this project"
falls, so does the decision.

This is sensitivity analysis's real function. It does not produce a number; it puts **the
decision's breaking point** in writing. Once the breaking point is known, whether the
decision needs revisiting when project conditions change stops being debatable.

#### The Decision's Reversibility

The final criterion for the choice is what happens if the choice turns out wrong. This
too can be counted.

```js
// migration.mjs — measuring how much of a decision stays reversible
// Coupling: 0 = independent of the framework, 1 = via interface, 2 = directly bound.
const SEPARATED = [
  { name: "threshold and date validation rules", lines: 180, coupling: 0 },
  { name: "measurement formatting and unit conversion", lines: 120, coupling: 0 },
  { name: "option-list state machine", lines: 260, coupling: 0 },
  { name: "accessibility attribute derivation", lines: 90, coupling: 0 },
  { name: "data access layer", lines: 310, coupling: 1 },
  { name: "presentational components (markup)", lines: 940, coupling: 1 },
  { name: "containers and hooks", lines: 380, coupling: 2 },
  { name: "routing and form binding", lines: 360, coupling: 2 },
];

// The same application, without the separations: logic lives in the component body.
const UNSEPARATED = [
  { name: "components (logic in the body)", lines: 2210, coupling: 2 },
  { name: "data access layer", lines: 310, coupling: 1 },
  { name: "routing and form binding", lines: 360, coupling: 2 },
];

function measure(name, modules) {
  const total = modules.reduce((t, m) => t + m.lines, 0);
  const group = (level) => modules.filter((m) => m.coupling === level).reduce((t, m) => t + m.lines, 0);
  const independent = group(0), viaInterface = group(1), direct = group(2);
  // Rewritten during migration: all of what is directly bound, half of what is bound via interface.
  const rewritten = direct + Math.round(viaInterface / 2);
  console.log(`\n--- ${name} ---`);
  console.log("module                                    lines  coupling");
  for (const m of modules) console.log(`${m.name.padEnd(42)} ${String(m.lines).padStart(6)}  ${m.coupling}`);
  console.log(`total ${total} lines | independent ${independent} (%${((independent / total) * 100).toFixed(1)})` +
    ` | via interface ${viaInterface} | directly bound ${direct}`);
  console.log(`rewritten during migration: ${rewritten} lines (%${((rewritten / total) * 100).toFixed(1)})`);
  return rewritten;
}

const a = measure("separated", SEPARATED);
const b = measure("not separated", UNSEPARATED);
console.log(`\nwhat the separations gained in migration: ${b - a} lines`);
```

```

--- separated ---
module                                    lines  coupling
threshold and date validation rules           180  0
measurement formatting and unit conversion    120  0
option-list state machine                     260  0
accessibility attribute derivation             90  0
data access layer                             310  1
presentational components (markup)            940  1
containers and hooks                          380  2
routing and form binding                      360  2
total 2640 lines | independent 650 (%24.6) | via interface 1250 | directly bound 740
rewritten during migration: 1365 lines (%51.7)

--- not separated ---
module                                    lines  coupling
components (logic in the body)               2210  2
data access layer                             310  1
routing and form binding                      360  2
total 2880 lines | independent 0 (%0.0) | via interface 310 | directly bound 2570
rewritten during migration: 2725 lines (%94.6)

what the separations gained in migration: 1360 lines
```

The same application, with two different internal layouts. With the separations made, a
quarter of the code is entirely independent of the framework, and the portion to rewrite
in a migration drops by half. Without them, independent code is zero and nearly the whole
application gets rewritten.

The four modules that stay independent are not a coincidence: validation rules,
formatting, the state machine, and accessibility attribute derivation — all products of
the previous topic's separations: presentation from container, behavior into a headless
core, logic into hooks. Their value is not limited to readability; they are also what keeps
the framework decision reversible.

This leads to a conclusion that softens the selection debate. When the
independent-code ratio is kept high, the framework decision is a choice, not a fate. The
weight of the decision is inversely proportional to its reversibility.

#### The Decision in Writing

The decision framework's output is not a framework name; it is a record. The record has to
carry five things.

Context: what the application is, its expected lifespan, team size and experience.
Criteria and weights: which criterion outweighs which, and why, with numbers. The
candidates evaluated and why each was eliminated. The assumptions the decision rests on,
and the breaking points from sensitivity analysis. The review condition: which event
brings the decision back up for reconsideration.

The most useful part of this record is the last one. The sentence "this will be revisited
if this condition changes" makes the decision both defensible and changeable; a decision
without a record loses its rationale over time and persists only as habit.

#### Summary

- Criteria come from three sources: those derivable from the family, those read from the
  candidate, and those read from the team. Only the first follows from the family's
  definition.
- When weights are written explicitly, the same score table produces different rankings
  for different projects; the difference comes from the weights, not the families.
- The update model and scope are two independent axes and are decided separately.
- What matters is not the ranking itself but the gap between first and second; a one-point
  gap means "not separated," and sensitivity analysis puts the decision's breaking point
  in writing.
- The decision's reversibility can be measured; with the separations made, the portion to
  be rewritten in a migration dropped from 94.6% to 51.7%.
- The output is not a framework name but a written decision record that includes the
  review condition.

#### Course Wrap-Up

This course began with the scaling problems of manual DOM management and built a one-way
flow from state to view. The component was defined as a three-channel contract of props,
output events, and local state, carried all the way through with lifecycle, side effects,
derived values, context, and error boundaries.

In the second half, components were composed together. The choice between wrapping and
slot-based composition was justified by counting prop drilling and option explosion.
Logic moved into hooks and composables, families of components that work together were
built, and data, behavior, and view split into three layers. The component's outer
surface — prop spreading, default merging, control ownership — was designed as a
contract.

The final topic covered the update mechanisms underneath these components. The same
change in the same two-hundred-row measurement table produced work ranging from sixteen
hundred comparisons down to a single write, depending on the family. The four families'
characteristics, costs, and decision surfaces were laid out with numbers and turned into
a decision framework that never names a single product.

There is a gap where the course leaves off. Everything so far lived inside a single
component tree: state is born in a component, flows down through it, and gets consumed
there. But the station application is not a single screen — the measurement table is at
one address, the archive view at another; the filter preference has to persist across two
screens; measurement data comes from the server, and its browser copy does not know when
to refresh. None of this is solved by component composition; it belongs to the layer
**above** components.

The next course — **Application Architecture: Routing, State and Data** — builds this
layer: how an address maps to a view, how nested layouts share a shell, why local,
shared, and server state are managed separately, caching and invalidating server data,
sharing form state and validation schemas, and the options and risks of browser
authentication. This course's composition and surface-design habits hold there too — the
owner of state, the boundary of the contract, and the reversibility of the decision are
the same questions, only at greater scale.

