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

TS
each<T>(source: { value: readonly T[] } | (() => readonly T[]), key: (item: T, index: number) => unknown, render: (item: T, index: number) => Node): () => Node[]

Parameters

PARAMETERTYPEDESCRIPTION
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

JS
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.

JS
// 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

src/islands/list.ts
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);
live preview● running

See also