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.
- 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 —
.listchains 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
.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.
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.
[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
Mapkeys: 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);
[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 →
renderdoes not run again - reordered
- nodes move; the
data-aebindings 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.
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; }
}
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.