Ramonda

Rendering lists

To draw a list from an array — rows of tasks, a set of cards — use list():

class TaskList extends Component {
  @state tasks: Task[] = [];

  render() {
    return <ul>{list(this.tasks, (item) => <TaskRow item={item} />)}</ul>;
  }
}

click a few counters, then reverse — each count moves with its task

  • write the docs
  • fix the diff
  • ship it

Two arguments: the array, and the one way to turn an item into markup — a component (it receives the item as its item prop) or a function. Click a few counters, then reverse the list — each count stays with its task.

Which row is which

A list has to answer one question whenever the array changes: which row on screen is which row in the new array. Get it wrong and a row's state lands on its neighbour — a half-typed input, an open menu, a scroll position, all one row off, while the page still looks right.

There are three answers, tried in this order, and the first two are exact.

The object. While a row is the same object it is the same row. Nothing is declared and nothing can be got wrong. This covers every update that keeps its references — filter, a spread that touches one row, a lens write. A repeated primitive is told apart by which occurrence it is, so ["a", "a", "b"] is three stable rows.

Your key. The moment an object is new — data from a refetch, a JSON.parse, an array built fresh in a @compute — the object cannot answer, because nothing here has seen it before. A key is what still can:

list(this.users, (user) => <UserRow key={user.id} item={user} />);

Write it from your data, never from the array index — the index is the position, so keying by it says "this is the second row", which is exactly what stops being true when rows move. Two rows under one key are reported (RMD002).

A guess. With no key and a new object, the incoming array is aligned against the one on screen by what the rows still have in common. It is right for the shapes data takes — see refetched data — and it is a guess, which is why a key beats it.

It does not iterate where you write it

This is the one thing that separates list() from a .map(), and it is the one thing you cannot see:

list(this.tasks, (task) => <li>{task.title}</li>)

Nothing has run when that line finishes. The callback has not been called once. What comes back is a description — the array, and the way to turn one item into markup — and the framework calls your callback later, while it is reconciling the place the rows live in.

That is what pays for itself. When the array has not changed, the framework already knows nothing about the list can differ, so the callback is never called at all and not one row is touched. A .map() cannot do that: by the time anything can ask whether the work was needed, every row has already been built.

Skipping the callback needs a callback it can skip

The rule above has one condition, and it follows from what skipping means: to hand back last render's rows, the framework has to be sure the callback would produce the same ones. It knows every value the callback read — reads are tracked wherever they happen, however many helpers deep, in whatever module. What it cannot know is a value read outside the callback and closed over:

const label = this.label;
list(this.tasks, (task: Task) => <li>{task.title} {label}</li>)   // the row reads nothing

Nothing can look inside a closure and list what it captured. So the framework goes by the one thing it can see: an inline callback is a new function every render, and a new function might have captured anything — so its rows are rebuilt. A callback that cannot capture a render's locals has a stable reference, and its rows are reused:

class Board extends Component {
  @state tasks: Task[] = [];
  @state label = "";

  row(task: Task) {
    return <li>{task.title} {this.label}</li>;    // reads its state INSIDE — tracked
  }

  render() {
    return <ul>{list(this.tasks, this.row)}</ul>;
  }
}

A method is the form to reach for when a list is large. A module-level function counts too, for the same reason — it cannot see any render either.

And an inline callback is not wrong, it is just not skipped. It costs the callback call and a fresh vnode per row per render, and — measured at 10 000 rows over five re-renders — no extra DOM work at all: the diff finds the rows identical and touches nothing. So a short list keeps every guarantee and pays nothing you can notice, and only a very large one is worth moving to a method.

What you never get is a stale row from anything reactive. Every signal the callback reads while a row is built is recorded against that row — @state, @persist, a prop, a @compute over any of them — and a write marks exactly the rows that read it. That is the guarantee, and it is why the rule is the callback's shape rather than a promise to be careful.

The boundary is a value that is not reactive at all, and it is worth knowing because it looks like it works until you make the callback a method:

class Board extends Component {
  label = "old";                                    // not `@state`, and a row shows it
  row(task: Task) {
    return <li>{task.title} {this.label}</li>;      // ✗ nothing records this read
  }
}

A plain field is not a signal, so nothing records the read and nothing marks the row. render() gets away with it because it re-runs whole — the markup outside the list shows the new value the next time anything re-renders — and a reused row does not re-run, so it keeps the old one. It is the same answer a @compute gives for a plain field, and the reason @state is for anything render() shows. Mark it @state, or leave the callback inline, which rebuilds every row and so reads it again.

ramonda-check reports this before it ships, as row-reads-a-plain-field — the runtime cannot, because a plain field read leaves no trace to observe. It stays quiet for a field nothing writes, a field written only in @created, an inline callback, and a read that never reaches the markup, which is what a plain field is for: a WebSocket or a Map cannot be @state, because that is serialised into the page.

It follows that the description is not a list of things you can look at:

const rows = list(this.tasks, (task) => <li>{task.title}</li>);

rows.length      // no
rows.map(...)    // no
[...rows]        // no

TypeScript refuses all three, and development throws with an explanation if the types were bypassed. If what you want is an array of values rather than a rendered list, that is .map() — and .map() is a perfectly good way to render one too, as long as you give the rows a key.

It's a function, not a <List> tag

A component is one element, but a list drops several siblings into the parent — so list() is a plain function call in a { } slot, not a tag. That is what lets a list sit anywhere an expression can:

{this.open ? list(this.results, (item) => <ResultRow item={item} />) : null}

<ul>{list(this.todo, (item) => <TaskRow item={item} />)}</ul>
<ul>{list(this.done, (item) => <TaskRow item={item} />)}</ul>

each is read when the list is built, so it is always the current array. It accepts null and undefined and renders nothing for them, which is what data that has not arrived yet looks like:

list(this.query.data, this.renderRow)   // no `?? []`

That is not politeness — this.query.data ?? [] builds a fresh empty array on every render, and a changed array costs the list its item scopes. RMD020 reports it.

Lists of primitives, and repeated values

["a", "a", "b"] is fine — repeated values are told apart by which occurrence they are, so each gets its own stable row. (If you genuinely need to tell two apart, they aren't really interchangeable values — model them as objects with an id.)

Refetched data, and objects that are re-created

Data from outside is the hard case. A refetch, a JSON.parse, anything round-tripped through the network hands you fresh objects meaning the same rows — nothing shares a reference with what is on screen. Matching by reference finds none of them.

list() handles this for you. When it meets an array whose objects it has not seen, it aligns the new array against the one it is showing and carries each row's identity across. So a refetch updates its rows instead of destroying and rebuilding them:

this.users = await api.getUsers();   // every object is new

A row whose name changed keeps its DOM node, its component instance, and whatever that component was holding — a half-typed input, an open menu, a scroll position. Nothing to write, nothing to get wrong.

How the alignment works

Rows that are equal by content are matched first — those are the anchors. Whatever sits between two anchors in the old array is then paired with whatever sits between the same two anchors in the new one, by how much the two still have in common. That is what carries a row that changed: its unchanged neighbours place it, and what it still shares with them decides which one it is.

No field is special by NAME. An id counts for exactly as much as a title, because a framework cannot know which of your fields is an identity — you may well build one array from another and repeat an id, and a rule that trusted id would quietly merge two rows.

What does count is how much a field distinguishes. A value several rows share identifies none of them and is ignored, and so is a field that merely restates the row's position (an index), because position is not identity.

What is deliberately not carried

A list replaced with different data. A pair is only made when two rows still agree on a field that tells rows APART. A done: false shared by every row says nothing about which row it is, so it does not count — which is why page 2 of a table inherits nothing from page 1, even though both pages are full of the same flags. The guard is per row, and it is about how much a field distinguishes rather than how many fields match.

A copy. { ...row } gives you a new row, not a second claim on an old one. Identity is a non-enumerable symbol, so a spread, a JSON.stringify and every equality check you write are all blind to it. (Object.freeze is fine — a frozen row keeps its identity too, just somewhere the freeze cannot reach.)

A row with nothing to identify it. { tags: [...] } and nothing else: there is no field to pair it on, so a change to one of them rebuilds that row. This is the case merge is for.

When you know better than the inference

list() reads your rows and decides. For the shapes data takes that is right — but it is inference, and it has no way to be told otherwise. merge is that way, and it sits where the data arrives rather than on the list:

this.rows = merge(this.rows, await api.getRows(), (row) => row.id);

Said once, at the boundary — not on every list that renders those rows.

With an identity, rows are paired by what you named. An unchanged row comes back as the same object wherever it moved to, and a changed one comes back carrying its predecessor's identity, so the row updates in place instead of being rebuilt.

Without one, merge still earns its place: it hands back the previous value wherever the new one equals it, so a refetch that changed nothing is not a change at all and nothing re-renders.

this.rows = merge(this.rows, await api.getRows());

@ramonda/query does this for you on every fetch — there is nothing to turn on.

What about .map()?

.map() renders a list perfectly well, and it is not discouraged. What it needs is a key on every row (RMD023 asks for one), because a .map() has no identity of its own — without a key the rows are matched by position, so a reorder or a removal from the middle hands every row below it the previous row's state and DOM.

Plain markup looks like it gets away with that, because the diff patches the text and the result reads correctly. It does not: an <input> inside that <li> holds a caret, a selection and whatever the user typed, and those follow the node rather than the text.

One thing a missing key does not cost you is the boundary. Rows built from an array cannot be confused with the siblings around them, keyed or not — every array in JSX becomes its own group with its own key space, so an element toggling in or out beside the array never reaches into it, and the array never reaches out.

What list() adds is laziness. It does not iterate where you write it, so a list whose array did not change costs nothing at all — a 500-row table's render is 0.04% of its commit. It also gives each row its own reactive scope, so a signal one row reads invalidates that row and no other, and it identifies rows by the item itself, so a key is something you reach for rather than something you must remember.

Either is a fine choice. list() is the one that scales.

// A fixed set of tabs is a list too.
const TABS = ["overview", "activity", "settings"];

class Panel extends Component {
  @memoized
  select(name: string) {
    return () => {
      this.active = name;
    };
  }

  @state active = TABS[0];

  renderTab(name: string) {
    return <button type="button" onclick={this.select(name)}>{name}</button>;
  }
  render() {
    return <nav>{list(TABS, this.renderTab)}</nav>;
  }
}

TABS lives outside the class on purpose: a fresh array literal every render is a new value every render, and identity is minted from the items.

Next