REFERENCE / CORE API
each()
Keyed mapping for a list hole. render runs once per key while the key stays present; on every later change the existing nodes are moved by a move-minimal reconciler, never rebuilt — which is what keeps DOM state (focus, what was typed, scroll) with its row.
Signature
each<T>(source: { value: readonly T[] } | (() => readonly T[]), key: (item: T, index: number) => unknown, render: (item: T, index: number) => Node): () => Node[]
Parameters
source
Signal | () => T[]
A signal or computed holding an array, or a function returning one. Replace the array to change the list — items are never mutated in place.
key
(item, index) => unknown
Identity. Mandatory by position, so it cannot be forgotten; a duplicate key throws. Index-keying is possible (`(it, i) => i`) and carries the usual state-sticks-to-position risk.
render
(item, index) => Node
Builds a row. Runs inside its own detached root(): reorders and appends above never dispose a living row, and a leaving key disposes exactly its own scope.
Returns
A function — so by the hole rule it becomes an effect that re-runs when source changes. Place it directly in a child hole.
Basic
const todos = signal([{ id: 1, text: 'ship it', done: signal(false) }]);
const Todo = (t) => html`<li class="${() => (t.done.value ? 'done' : '')}">${t.text}</li>`;
html`<ul>${each(todos, (t) => t.id, Todo)}</ul>`;
todos.value = [...todos.value, { id: 2, text: 'write docs', done: signal(false) }]; // one new <li>; the first is untouched
todos.value[0].done.value = true; // one class write; nothing re-renders
When an item changes but its key does not
The cached row is not re-rendered — that is the contract that makes moves cheap. Put signals inside items for the parts that change (done above), and the row's own holes update in place. A key that disappears drops out of the cache; if it returns later it gets a fresh node.
Move-minimal
Reconciliation skips the unchanged prefix and suffix, then applies a longest-increasing-subsequence pass to the middle: a swap costs 2 moves, a reverse n−1, a rotate 1. The repository's DOM-work gate asserts those numbers.
each() or .map()?
each() is a client-side keyed reconciler; it throws on the server. In a page rendered by amo build ssg or ssr, render lists with .map() — on the server there is nothing to reconcile.
// plain .map(): rebuilds every <li> on each change — fine for small, static-ish lists, and the only option on the server
html`<ul>${() => items.value.map((it) => html`<li>${it}</li>`)}</ul>`;
Try it
import { signal, each, html, mount } from '@amojs.dev/core';
type Item = { id: number; label: string };
const items = signal<Item[]>([
{ id: 1, label: 'alpha' },
{ id: 2, label: 'bravo' },
{ id: 3, label: 'charlie' },
]);
let nextId = 4;
let renders = 0;
// render runs ONCE per key; a reorder moves the same <li>, it never rebuilds it
const Row = (it: Item) => {
renders++;
return html`<li>${it.label} <input aria-label="a note for this row" placeholder="type here, then shuffle" /></li>`;
};
const shuffle = () => (items.value = [...items.value].sort(() => Math.random() - 0.5));
const add = () => (items.value = [...items.value, { id: nextId, label: `item ${nextId++}` }]);
const drop = () => (items.value = items.value.slice(0, -1));
const Demo = () => html`<div class="demo">
<ul>${each(items, (it) => it.id, Row)}</ul>
<div class="row">
<button class="counter" onclick=${shuffle}>shuffle</button>
<button class="counter" onclick=${add}>add</button>
<button class="counter" onclick=${drop}>remove last</button>
</div>
<p>rows rendered so far: <b>${() => (items.value, renders)}</b> — shuffling does not add to it, and what you typed travels with its row</p>
</div>`;
const host = document.getElementById('demo');
if (host) mount(Demo, host);