Rendering lists
render() {
return <ul>{list({ each: this.tasks, as: TaskRow })}</ul>;
}
click a few counters, then reverse — each count moves with its task
- write the docs
- fix the diff
- ship it
Click a few of the counters, then reverse the list. Each count stays with its task.
Why list() takes the key away
Other frameworks ask you for a key, and the ask is the problem.
A wrong key is an accident; .map() is a decision. You can teach a decision. You cannot
defend against an accident — a key derived from the index moves with the position instead of the
item, and the symptom is not an error but state appearing on the wrong row. It can be typed
wrong, forgotten, or collide, and a runtime check only catches it if that branch happens to run.
So list() does not take a key. Identity is the item — its object reference, or its value
for primitives. There is nothing to write, so there is nothing to get wrong.
It is a function, not a component
<List> cannot be a component, because a component is exactly one element and a list needs to
put N siblings into the parent. So it is a plain function call in an expression slot — the same
thing Ramonda already tells you to do when you need vnodes from a function.
That shape is the point. It is an expression, so:
// A list that may never exist costs nothing until it does.
{this.open ? list({ each: this.results, as: ResultRow }) : null}
// Several lists are several calls, each next to the markup it produces.
<ul>{list({ each: this.todo, as: TaskRow })}</ul>
<ul>{list({ each: this.done, as: TaskRow })}</ul>
Nothing is declared, nothing runs unless the call is reached, and each is read at the moment
the list is built — so it is always the current array.
The same item twice
[tag, tag] works. Each occurrence gets its own identity, and both rows keep their own state.
Reference identity cannot tell two occurrences apart — and neither could a hand-written key.
Arrays of primitives
string[], number[] — a list of primitives needs no key either, and reaching for one is a
mistake. Identity is the value, and repeated values are told apart by which occurrence they
are: ["a", "a", "b"] gives the first "a" and the second "a" their own stable rows.
A key here could only be the value, and two equal strings return the same key — exactly the
collision reported as RMD013. That is the point turned around: because two primitives can be
equal, a key is the wrong tool, not the right one. Nothing a key computes can distinguish two
equal values, because there is nothing to distinguish them by.
That indistinguishability is the one limit worth knowing. If those rows own state and you remove
or reorder a duplicate, the state stays with the position of the occurrence, not with "that
particular "a"" — equal values are genuinely interchangeable. When you truly need to tell two
of them apart, they are not really primitives: model them as objects with an id, and the id is the
identity.
key as an override
There is exactly one case a list cannot see: objects re-created as fresh instances that mean the same entity — a refetch, a deserialize, a round trip through JSON.
list({
each: this.users,
key: (user) => user.id,
as: UserRow,
})
Reach for it only then. When you do, collisions are checked (RMD013), because that is the
one place a mistake is possible again.
The same situation arrives from a different direction whenever you update state immutably:
replacing an item gives it a new object, so an unkeyed list sees a new entity and that row
resets. Nothing lands on the wrong row — state is reset, never wrong — but when a row owns state
you care about, key is how you say "this is still the same thing".
What .map() costs you
.map() still works, and for a static list — a nav bar, a set of tabs you never reorder — it is
fine.
What it costs is identity. Without keys the diff matches children by position, so a list that reorders or has items removed from the middle moves component state to the wrong row:
before a#0 b#0 c#0 (each with its own state)
remove b, unkeyed
after a#0 c#0 — c inherited b's node and b's state
The DOM looks right. It is the state inside those components that moved.
There is a second, subtler cost. An unkeyed list with siblings after it lets the diff claim
the wrong node when the list grows — a component's own chrome can be mistaken for a list item.
Development reports it; list() prevents it, because the vnodes come out with identity and the
diff claims by identity rather than by position.
Next
asandrender— the two ways to map an item.