[]previous and next page

Lists

A list is where "touches more than it should" shows up.

For agentsUpdated

A list is where "touches more than it should" shows up. Rules:

  1. Use For with a key for lists of objects whose identity can change. Rows whose key survives an update keep their DOM and state; only new keys create rows and only missing keys dispose them. A new object under the same key updates the row's item() accessor instead of recreating the row.
  2. Unkeyed For matches rows by item reference. Replacing every object (a fresh map from a server response) recreates every row. Add key.
  3. Index matches by position and is for primitive lists; the row at position i stays and its accessor follows the value.
  4. Do not use array.map in JSX for large or changing lists. The expression reruns and diffs the whole array on every change; the nodes it returns are created again unless they are cached. For keeps per-row ownership and reorders existing nodes.
src/rows.tsxtsx
import { useSignal } from "@shadow-js/core";
import { For } from "@shadow-js/web";

export interface Item {
  readonly id: number;
  readonly label: string;
}

export const rowRuns = { created: 0 };
export const state = {
  set: (_items: readonly Item[]): void => undefined,
};

function Row(props: { item: () => Item }) {
  rowRuns.created += 1;
  return <li>{props.item().label}</li>;
}

export function Keyed(props: { initial: readonly Item[] }) {
  // useSignal: the benchmark replaces the whole list on purpose (a store would reconcile it).
  const [items, setItems] = useSignal(props.initial);
  state.set = setItems;
  return (
    <ul>
      <For each={items()} key={(item) => item.id}>
        {(item) => <Row item={item} />}
      </For>
    </ul>
  );
}

export function Unkeyed(props: { initial: readonly Item[] }) {
  // useSignal: the benchmark replaces the whole list on purpose (a store would reconcile it).
  const [items, setItems] = useSignal(props.initial);
  state.set = setItems;
  return (
    <ul>
      <For each={items()}>{(item) => <Row item={() => item} />}</For>
    </ul>
  );
}

With stable keys, reversing, shuffling or removing items moves or disposes existing rows. Replacing an object with a new object that has the same key updates the row’s bindings while keeping its DOM nodes.

Selection: useSelector#

"Which row is selected" is the classic O(n) trap: if each of 1 000 rows computes selected() === id, one click re-runs 1 000 bindings. useSelector keeps one boolean per key, so only the previous and the next key rerun:

src/selection.tsxtsx
import { useSelector, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";

export const evaluations = { naive: 0, selector: 0 };
export const select = { set: (_id: number): void => undefined };

const ids = Array.from({ length: 500 }, (_, id) => id);

export function Naive() {
  const [selected, setSelected] = useStore(-1);
  select.set = setSelected;
  return (
    <ul>
      <For each={ids}>
        {(id) => {
          const isSelected = (): boolean => {
            evaluations.naive += 1;
            return selected() === id;
          };
          return <li class:on={isSelected()}>{id}</li>;
        }}
      </For>
    </ul>
  );
}

export function Selector() {
  const [selected, setSelected] = useStore(-1);
  select.set = setSelected;
  const isSelected = useSelector(selected);
  return (
    <ul>
      <For each={ids}>
        {(id) => {
          const on = (): boolean => {
            evaluations.selector += 1;
            return isSelected(id);
          };
          return <li class:on={on()}>{id}</li>;
        }}
      </For>
    </ul>
  );
}

Many signals#

A signal is a small object plus its read and write closures; a useStore per row for 20 000 rows is 20 000 objects the page keeps alive for its lifetime and 20 000 more to dispose on unmount. Prefer one store or one array signal for the data and per-row derivations from it: useStore creates the signal for a property only on the first read made inside a tracked computation, so untracked data costs one small record per object, and an effect that reads one field of one row subscribes to exactly that field. Keep per-row signals for state that belongs to a row and is not part of your data (an open/closed toggle).