---
title: 'Conditional and List Rendering'
source: 'https://academia.sh/en/courses/component-based-development/conditional-and-list-rendering'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

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