CORE
Lists
Two tools, one rule. .map() inside a function hole rebuilds the list on every change; each() keys the rows and moves existing nodes instead. Either way the array is a value — replace it, never mutate it in place.
.map() — rebuild
const Tags = (tags) => html`<ul>${() => tags.value.map((t) => html`<li>${t}</li>`)}</ul>`;
// every change rebuilds every <li>. Simple, and the right tool for a handful of static strings
each() — keyed
The key function is the second positional argument, so it cannot be forgotten. Rows render once per key; later changes move the nodes with a move-minimal pass (a swap costs 2 moves, a reverse n−1, a rotate 1).
const Todos = (todos) => html`<ul>
${each(todos, (t) => t.id, (t) => html`<li><input value="${t.text}" /> ${t.text}</li>`)}
</ul>`;
// a reorder MOVES the existing <li>s — what was typed into an input travels with its row
Replace, do not mutate
There is no Proxy and no deep reactivity, deliberately: a signal sees a write to .value and nothing else. Mutation you cannot see is also mutation amo eject could not turn into code you would have written.
items.value.push(x); // nothing happens: the array is the same object
items.value = [...items.value, x]; // one new row
items.value = items.value.filter(keep); // the leaving rows are disposed, the rest untouched
Signals inside items
When an item's data changes while its key stays, the cached row is not re-rendered — that is what makes moves cheap. Give the item a signal for the part that changes and the row's own holes update in place.
const todos = signal([{ id: 1, text: 'ship', done: signal(false) }]);
// toggling done writes ONE class on ONE row; the list effect does not even run
todos.value[0].done.value = true;
On the server
each() is a client-side reconciler and throws on node. In pages rendered by amo build ssg or ssr, use .map() — there is nothing to reconcile in a string.