Conditional and filtered lists
A list is rarely the whole array. It appears only when a panel is open, or it is the part of the
data that matched a filter. Both are the case list() was shaped for, because it is an
expression, not a component.
star a row, then filter β a row that stays keeps its own star, and no row inherits one from a person the filter removed
- Ada compilers
- Grace runtimes
- Edsger semantics
- Barbara genomics
- Katherine trajectories
Star a row, then narrow the filter. Every row that stays keeps its own star β none slides onto a neighbour, and none inherits the star of a row the filter just removed. That is the whole point, and the rest of this page is why.
(A row filtered away entirely is unmounted, so its star goes with it β bring it back and it
returns fresh. That is not a lost update; it is a component that stopped existing and started
again. The identity that list() protects is the identity of the rows that stay.)
The filter is a derived value, not stored state
The mistake is to keep a second array in state and update it from the search box:
// β Two sources of truth. Now every edit to `people` has to remember to redo
// the filter, and a missed one shows stale rows with no error.
@state people: Person[] = [...];
@state visible: Person[] = [...];
onInput(event: Event) {
this.query = (event.target as HTMLInputElement).value;
this.visible = this.people.filter(match); // and again on every add, remove, editβ¦
}
The filtered list is derived from the data and the query, so it is a @compute:
@state people: Person[] = [...];
@state query = "";
@compute get visible() {
const q = this.query.trim().toLowerCase();
if (!q) return this.people;
return this.people.filter((p) => p.name.toLowerCase().includes(q));
}
There is one source of truth. visible recomputes only when people or query changes, and it
can never disagree with them, because it is not stored anywhere to fall behind.
each binds to the derived array
<ul>{list({ each: this.visible, as: PersonRow })}</ul>
each is read at the moment the list is built, so it is always the current filter. Nothing
declares a dependency on query: the read of this.visible in render is the dependency, and the
list is rebuilt from whatever that returns this time.
Why not this.visible.map(...)
Because a filter is exactly where .map() costs you. Filtering removes items from the middle of
the list, and an unkeyed .map() matches the survivors to nodes by position:
before Ada#0 Graceβ
#1 Edsger#2 (Grace is starred)
filter out Grace, with .map()
after Ada#0 Edsgerβ
#1 β Edsger took Grace's slot, and her star
The DOM looks right β three names became two. But Edsger's row is now sitting on Grace's old
component instance, so it shows her star. list() claims nodes by identity instead, so the
survivors stay themselves:
before Ada#0 Graceβ
#1 Edsger#2
filter out Grace, with list()
after Ada#0 Edsger#2 β Edsger is still Edsger, unstarred
That is what you just watched the demo do: remove the starred row from the middle, and no one else lights up.
No key is needed for this, because filtering returns the same objects it selected from, and identity is the object. A key earns its place only when the objects are re-created as fresh instances for the same entity β a refetch, a deserialize β the case the intro covers.
When the list is absent entirely
The conditional case is the same shape. Because list() is a call in an expression slot, a list
that may never exist costs nothing until it does:
{this.query ? <ul>{list({ each: this.visible, as: PersonRow })}</ul> : null}
And an empty result is just an empty array β the list renders no rows, and the surrounding markup decides what to show instead:
{this.visible.length === 0 ? <p>No matches.</p> : null}
Nothing special happens for zero items. There is no list to tear down and none to declare, because there was never a list β only a call that returned nothing this time.
Next
- Extending components β the unit of reuse.