reference · 5 of 5

Lists

.list is the one place ae creates DOM for you. It clones the single root element of the container's own <template> once per item and keeps the stamped nodes reconciled by key — reused, moved, or removed — while every data-ae element inside them mounts through the normal lifecycle. No template syntax: the prototype is markup you wrote, and reconciliation is keyed node reuse.

.list(items, render, key?)

Stamps one node per item into every element the handle matches, and keeps stamping as items changes. Each stamped node gets its own effect running render(el, item, index), so a node re-renders when its item is replaced by key, when its index moves, or when any signal read inside the callback changes — and only then.

.list<T>(items: Reactive<readonly T[]>, render: (el: HTMLElement, item: T, index: number) => void, key?: (item: T, index: number) => unknown): Handle
items
a Reactive array: a signal or computed (reactive), a function (el) => readonly T[] called with the container and auto-tracked, or a plain array (stamped once)
render
runs per stamped node, in its own effect — the node first, then the item and its index
key
identity for reuse; defaults to (item) => item
returns
the handle — .list chains like every other binding
<ul data-ae="todos">
  <template>
    <li><b data-ae="title"></b> <i data-ae="due"></i></li>
  </template>
</ul>

const todos = ae.signal([{ id: 1, text: 'Ship it', due: 'fri' }]);

ae('todos').list(todos, (li, todo, i) => {
  const p = ae.parts(li);
  p.title.textContent = `${i + 1}. ${todo.text}`;
  p.due.textContent = todo.due;
}, (todo) => todo.id);

The four shapes items can take:

ae('rows').list(rows, renderRow);                  // Signal   — reactive
ae('rows').list(visible, renderRow);               // Computed — reactive
ae('rows').list(() => rows.value.slice(0, 10),     // function — auto-tracked
                renderRow);
ae('rows').list(['a', 'b'], renderRow);            // array    — stamped once
It is a binding, not a call — like .mount, .list applies to every element with that name, including containers connected later, and it appends: calling it twice on one handle runs two reconcilers over the same container. Wire each list once. When the container leaves the DOM the reconciler is disposed, every stamped node is removed, and every item effect goes with it.
Never create scoped handles in render — it re-runs, and ae(name, root) handles are cached and append-only, so the second run stacks duplicate bindings. Per-node wiring belongs in .scope — see nested lists.

The <template>

The container supplies its own item prototype: .list takes the :scope > template if there is one and otherwise the first descendant template, then deep-clones its single root element per item. Template content is inert, so the marked elements inside it bind nothing until a clone is connected.

lookup
direct child first, then the first descendant <template>
contents
exactly one root element — text and comments around it are ignored, a second element is an error
placement
stamped nodes are kept at the end of the container, in item order
<div data-ae="board">
  <p data-ae="empty">No columns yet.</p>      <!-- static: stays on top -->
  <template>                                 <!-- :scope > template wins -->
    <section data-ae="column">
      <div data-ae="cards">
        <template><article data-ae="card"></article></template>
      </div>
    </section>
  </template>
</div>

ae('empty').show(() => cols.value.length === 0);

Because stamps land at the end, static markup — an empty state, a header row, the <template> itself — can live in the container above them and stays untouched. And because the direct-child lookup wins, an outer list never mistakes an inner list's template for its own.

Two ways to no-op — a container with no template logs [ae] .list container has no <template>:, and a template whose content is not exactly one element logs [ae] .list <template> must have exactly one root element:. Both go to console.error with the container and bind nothing; the rest of the page keeps working.

live

static markup — every stamp is appended below it

Nothing stamped.

Keys

The key answers one question per reconcile run: is this item the one that already owns a node? It defaults to the item itself, which is exactly right for unique primitives and for object items you keep by reference. The moment you replace items immutably — { ...row, done: true } — identity changes on every update, so pass a stable id or nothing is ever reused.

key
(item, index) => unknown — the index is there for data with no natural id
default
(item) => item — item identity
compared
as Map keys: by value for primitives, by reference for objects
// Default: the item itself. Unique strings need nothing else.
ae('tags').list(tags, (el, tag) => { el.textContent = tag; });

// Rows you replace immutably need a stable id, or every update
// throws away every node and stamps a fresh one.
ae('rows').list(rows, renderRow, (row) => row.id);

// No natural id? The index is the honest fallback — nodes then
// belong to positions rather than to items.
ae('lines').list(lines, renderLine, (line, i) => i);
Duplicate keys — the first item to claim a key keeps it; collisions later in the same run are logged once as [ae] .list duplicate key, falling back to unkeyed node: and get a fresh node that can never be reused, so they are re-stamped on every run. Every item still renders — the duplicates only lose node reuse.

Reconciliation

Every run of the reconciler walks the new array once, matches keys against the nodes it already has, drops what vanished, then positions the survivors back-to-front — only mispositioned nodes actually move. What that buys you, precisely:

reused key
keeps its DOM node; the item and index are written into that node's signals
unchanged
same item reference at the same index → render does not run again
reordered
nodes move; the data-ae bindings inside them are not remounted
vanished
node removed, its item effect disposed, its inner bindings cleaned up
unmounted
container out of the DOM → reconciler and every item effect disposed

The no-remount guarantee is what makes state inside a row survive a shuffle — the state ae or the element itself owns: a .mount that stored a timestamp, an open <details>, a value typed into an input, a class toggled by .cls. Moves are moves — net-state lifecycle means reparenting a connected element never re-fires its bindings. Browser-owned state is a different question: a reorder is insertBefore on a node that already has a parent, which detaches it first, so focus leaves the row and an in-flight CSS animation restarts.

<div data-ae="rows">
  <template>
    <div>
      <span data-ae="id"></span> <b data-ae="title"></b>
      <span data-ae="mounted"></span> <button data-ae="del">✕</button>
    </div>
  </template>
</div>

const rows = ae.signal([{ id: 1, text: 'parse' }, { id: 2, text: 'stamp' }]);

ae('rows').list(rows, (el, row, i) => {
  const p = ae.parts(el);
  p.id.textContent = `#${row.id}`;      // stable across a shuffle
  p.title.textContent = `${i + 1}. ${row.text}`;   // index re-renders
}, (row) => row.id);

ae('mounted').mount((el) => {           // once per node, never on a move
  el.textContent = new Date().toLocaleTimeString();
});

ae('del').press((btn) => {
  const row = ae.itemOf(btn);           // which node was clicked
  rows.value = rows.value.filter((r) => r !== row);
});

Handlers inside stamped nodes recover their item with ae.itemOf, and named children come back through ae.parts — no keys stamped into the DOM, no dataset.id round-trips.

live

Shuffle: the id and the mount time travel with each row, only the index number changes. Add: one new node, one new timestamp — the rest are untouched.

Empty — add a row.

Nested lists

A list inside a list is three moving parts: the outer list stamps the roots, a .scope on each stamped root wires that root once, and ae(name, root) keeps the inner names private so every column can call its cards card. Derive the inner items with ae.computed from the outer item you recover via ae.itemOf.

ae('board').list(cols, renderCol, (c) => c.id);

ae('column').scope((colEl) => {            // once per stamped column
  const colId = ae.itemOf(colEl).id;       // the outer item
  const mine = ae.computed(() =>
    cards.value.filter((c) => c.col === colId));

  ae('cards', colEl).list(mine, renderCard, (c) => c.id);
  ae('count', colEl).text(() => mine.value.length);
  ae('card-del', colEl).press((btn) => {
    const card = ae.itemOf(btn);           // innermost item wins
    cards.value = cards.value.filter((c) => c !== card);
  });
});

.scope is what makes this survive column churn: the scoped handles first created inside its callback are retired when the root unmounts, so a column that comes back is rebuilt against fresh handles instead of stacking a second set of bindings onto the cached ones. Behavior that is identical in every column — ae('card-prio').press(…) — can stay global.

itemOf resolves innermost — inside a card, ae.itemOf(el) walks up to the nearest stamped node and returns the card. Reach the column from a card by carrying the column id on the card, or by binding the handler on the column's card container, whose nearest stamped ancestor is the column node.

Animating changes

Every reconciliation guarantee above holds within one container. An item that moves between two .list containers — a kanban card changing columns — is a removal in one list and a fresh stamp in the other. That is a new DOM node, so transient state on the old node does not travel with it: focus is lost, a running CSS transition restarts, scroll position resets.

Visually this is fixable. Wrap the mutation in ae.transition and give each stamped node a unique view-transition-name: the browser snapshots the old node, takes the new snapshot only once ae has settled, and morphs one into the other across the two containers.

const paint = (el, card) => {
  el.textContent = card.title;
  el.style.viewTransitionName = `card-${card.id}`;   // unique per item
};
ae('todo').list(open, paint, (c) => c.id);
ae('done').list(closed, paint, (c) => c.id);

ae('card').press((el) => ae.transition(() => {
  const card = ae.itemOf(el);
  cards.value = cards.value.map((c) =>
    c === card ? { ...c, done: !c.done } : c);
}));

The animation itself is CSS. ae.transition returns whatever document.startViewTransition returns, or undefined where View Transitions are unsupported — there fn runs plainly and the change applies at once, so this is an enhancement, never a requirement.

::view-transition-group(*) { animation-duration: 250ms; }
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) { animation: none !important; }
}
Reduced motion is yours — the browser runs the transition regardless; only your CSS can honor prefers-reduced-motion. And keep view-transition-name unique per visible element: two elements sharing a name in the same snapshot skip the animation.

live

Click a chip to send it across. Two containers, two lists — the chip is destroyed and re-stamped, and the morph hides the seam. Chrome 111+ and Safari 18+ animate it; elsewhere it jumps.

left

right

All of it composed — nested keyed lists, per-column scopes, drag and drop, undo — is the kanban board on the front page.