Ramonda

Conditional and filtered lists

A list is rarely the whole array. It shows up only when a panel is open, or it is the part of the data that matched a filter. Both fit list() naturally, 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 removed.

(A row filtered out entirely is removed, 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. list() protects the identity of the rows that stay.)

The filtered list is a derived value, not stored state

The tempting mistake is to keep a second array in state and update it from the search box — now every edit to the data has to remember to redo the filter, and a missed one shows stale rows with no error.

Instead, 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));
}

One source of truth. visible recomputes when people or query changes and can never fall out of step, because it isn't stored anywhere to go stale.

Bind the list to the derived value

<ul>{list(this.visible, (item) => <PersonRow item={item} />)}</ul>

each is read as the list is built, so it is always the current filter. You don't declare a dependency on query — the read of this.visible in render is the dependency.

Why not this.visible.map(...)

Because filtering removes items from the middle, and a bare .map() matches survivors by POSITION. Here is the whole fault in three lines. Three people, and Bo's row has a star toggled on it — a @state on PersonRow, so it lives in the row's component and not in the data:

before      Ada          Bo ★         Cy
components  [ row 0 ]    [ row 1 ]    [ row 2 ]

Now the filter drops Bo. With .map() the survivors are [Ada, Cy], and position is all the diff has to go on:

after       Ada          Cy
components  [ row 0 ]    [ row 1 ]   ← Bo's component, and Bo's star

Cy is now rendered by the component that was Bo's, so Cy shows Bo's star — and nothing on the page looks broken, which is what makes it expensive to find. The same happens to a half-typed input, an open menu, a scroll position.

list() matches by identity instead, so Ada and Cy keep their own components and the one that was Bo's is destroyed. No key is needed, because filtering returns the same objects it selected from.

When the list isn't there at all

Same shape — it is a call in an expression slot, so a list that may never exist costs nothing until it does, and an empty result is just an empty array:

{this.query ? <ul>{list(this.visible, (item: Person) => <PersonRow key={item.id} item={item} />)}</ul> : null}
{this.visible.length === 0 ? <p>No matches.</p> : null}

Next

  • Hooks — reusable state and lifecycle with no element.