Skip to content
academia.sh

Lesson 01 / 25

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.

Contents

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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close