Skip to content
academia.sh

Lesson 02 / 25

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.

Contents

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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close