Skip to content
academia.sh

Lesson 05 / 25

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.

Contents

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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close