Lists
Lists are the answer to the obvious objection: if the tree is decided at build time, how does anything dynamic work?
The shape
{
todos.map((t) => <Row title={t.title} mark={t.mark} />, { key: (t) => t.id });
}
key is required.
What actually happens
The callback runs once, at build time, with a recording proxy instead of a real
item. Reading t.title does not produce a value — it produces a path. What gets
compiled is a template plus an arena of item slots, and the runtime fills slots by
reading those paths out of your array.
So a list of 500 rows does not build 500 trees. It builds one template, materializes some slots, and writes text and style values into them.
Adding, removing and reordering items splices slots. Nodes are never renumbered, which is why the engine can keep holding indices into the same tables.
Why the key is required
The runtime has to know which slot holds which item in order to update the right one, and to move a slot rather than rebuild it when the array reorders. There is no tree to diff, so identity cannot be inferred from position.
The restriction that follows
Because the callback runs once with a proxy, an item template cannot contain a conditional:
// Wrong. The proxy is always truthy, so every row takes the first branch.
todos.map((t) => <div>{t.done ? "x" : " "}</div>, { key: (t) => t.id });
Nothing throws. Every row renders the same thing, which is exactly the kind of quiet wrongness that is worse than a crash.
Anything conditional per row has to be data. Compute it where real values exist:
export const view = computed(() =>
todos.map((t) => ({
...t,
mark: t.done ? "[x]" : "[ ]",
})),
);
Then the template just reads t.mark.
Note that this inner .map takes no key, so it is an ordinary build-time map over
real values. That distinction is deliberate: only a keyed call means "compile a
dynamic list", because computed bodies legitimately map over a signal's array and
hijacking those would turn derived data into a compile error.
Per-row handlers
One compiled handler serves every row. It receives the item that row is currently rendering:
export function toggleDone(item: Todo): void {
todos.set((ts) => ts.map((t) => (t.id === item.id ? { ...t, done: !t.done } : t)));
}
export function deleteTodo(item: Todo): void {
todos.set((ts) => ts.filter((t) => t.id !== item.id));
}
The runtime turns the clicked node back into a (slot, offset) pair, then looks up which item that slot holds.
Snapshotting on purpose
To take the static path deliberately, copy the array first:
const frozen = [...todos].map((t) => t.title);
A plain array has an ordinary .map, so this compiles the values in rather than
building a live list.
Capacity
.map accepts a capacity alongside key. It is the number of item slots to
materialize up front, and it defaults to headroom over the initial length. Exceeding
it grows the arena rather than truncating, so it is a tuning knob, not a limit.