Lists
Lists are where a rendering model is decided. Alacris gives you two, and the difference between them is where the work lands.
Every row gets its own reactive scope, built once:
import { html, each } from '@alacris/core';
html`<ul> ${each( () => todos(), // the source (todo) => html`<li>${() => todo().text}</li>`, // one row (todo) => todo.id // identity )}</ul>`Three consequences, and they are the reason each exists:
- Reordering moves nodes.
insertBeforeon the rows that actually moved. Focus, scroll position, text selection and what the user typed all survive. - A changed row wakes only itself. Rows are not rebuilt and neighbours are never consulted.
- Appending touches nothing else. Matching runs at the head and tail, so a push never looks at the rows already there.
import { define, html, each, signal, computed } from '@alacris/core';
let nextId = 4;
define('demo-todos', { styles: ` :host { display: grid; gap: .75rem; font: inherit; max-width: 26rem } form { display: flex; gap: .5rem } input[type=text] { flex: 1; font: inherit; padding: .35rem .6rem; border-radius: 6px; border: 1px solid currentColor; background: transparent; color: inherit } button { font: inherit; padding: .35rem .7rem; border-radius: 6px; cursor: pointer; border: 1px solid currentColor; background: transparent; color: inherit } ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .3rem } li { display: flex; align-items: center; gap: .55rem; padding: .3rem .1rem } li[data-done] .text { text-decoration: line-through; opacity: .5 } .text { flex: 1 } .x { border: 0; background: none; cursor: pointer; opacity: .55; font-size: 1.1rem; color: inherit } .bar { display: flex; justify-content: space-between; opacity: .7; font-size: .85rem } `, setup() { const items = signal([ { id: 1, text: 'Read the source', done: true }, { id: 2, text: 'Drop it into a page', done: false }, { id: 3, text: 'Ship it', done: false }, ]); const draft = signal(''); const left = computed(() => items().filter((t) => !t.done).length);
const add = (e) => { e.preventDefault(); const text = draft().trim(); if (!text) return; items([...items(), { id: ++nextId, text, done: false }]); draft(''); }; const toggle = (id) => items(items().map((t) => (t.id === id ? { ...t, done: !t.done } : t))); const remove = (id) => items(items().filter((t) => t.id !== id)); const shuffle = () => items([...items()].sort(() => Math.random() - 0.5));
return html` <form @submit=${add}> <input type="text" placeholder="Add something" .value=${draft} @input=${(e) => draft(e.target.value)} /> <button type="submit">Add</button> <button type="button" @click=${shuffle}>Shuffle</button> </form>
<ul> ${each( items, (todo) => html` <li ?data-done=${() => todo().done}> <input type="checkbox" .checked=${() => todo().done} @change=${() => toggle(todo().id)} /> <span class="text">${() => todo().text}</span> <button class="x" title="remove" @click=${() => remove(todo().id)}>×</button> </li>`, (todo) => todo.id )} </ul>
<div class="bar"> <span>${left} left</span> <span>Shuffle moves the existing nodes — the checkboxes keep their state.</span> </div>`; },});Tick a box, then press Shuffle. The checkboxes keep their state, because those are the same DOM nodes moved — not new ones rendered with the same data.
The row is a signal
Section titled “The row is a signal”row is a signal, so read it inside a thunk to keep the binding live:
each(items, (row) => html`<li>${() => row().text}</li>`, (r) => r.id)// ^^^^^^^^^^^^^^^^ liveWriting ${row().text} instead would read once and never update. That is
occasionally what you want — for a field that genuinely never changes, it is
one less subscription.
The index
Section titled “The index”Ask for a second parameter and you get the index, also as a signal, kept current as rows move:
each(items, (row, i) => html`<li>${() => `${i() + 1}. ${row().text}`}</li>`)It is only created if you ask for it.
Identity
Section titled “Identity”The third argument decides what “the same row” means. Without it, rows are matched by the item itself, so a new object is a new row:
each(items, render) // identity: the item referenceeach(items, render, (item) => item.id) // identity: the idUse a key whenever your data is replaced rather than mutated — which is most of the time with plain signals. With the store, rows are mutated in place and reference identity already works.
Create it where it runs once
Section titled “Create it where it runs once”each keeps its state as long as the same each(...) value stays in place —
which it does when the template is built once, in setup. Inside a re-run
thunk, every run calls each(...) again, and a fresh spec tears the whole
list down and rebuilds it:
html`<ul>${each(() => rows(), renderRow)}</ul>` // built once — rows persisthtml`<ul>${() => each(() => rows(), renderRow)}</ul>` // rebuilt on every run — don'tThe source is already a function, so there is nothing reactive to gain from
the outer thunk — pass each(...) directly as the child value.
Mapping the array yourself
Section titled “Mapping the array yourself”For a short, stable list, a plain .map is fine, and keyed gives items a
stable identity:
import { keyed } from '@alacris/core';
html`<ul>${() => items().map((i) => keyed(i.id, html`<li>${i.text}</li>`))}</ul>`Nesting
Section titled “Nesting”each nests, and inner lists react independently of outer ones:
html`<div> ${each( () => groups(), (group) => html` <section> <h3>${() => group().name}</h3> ${each(() => group().items, (item) => html`<li>${() => item().label}</li>`, (i) => i.id)} </section>`, (g) => g.id )}</div>`Rows with several roots
Section titled “Rows with several roots”A row does not have to be a single element. Multi-root rows are bracketed internally and move as a unit:
each(items, (row) => html`<dt>${() => row().term}</dt><dd>${() => row().def}</dd>`)What it costs
Section titled “What it costs”From the benchmark, against hand-written keyed DOM, JS and DOM mutation only:
| operation | vanilla | .map | each |
|---|---|---|---|
| append 1,000 to 10,000 | 2.50 | 10.0 | 6.20 |
| update every 10th row | 0.030 | 0.385 | 0.090 |
| swap 2 rows | <0.01 | 1.37 | 0.110 |
| remove a row | <0.01 | 0.365 | 0.055 |
Pair each with the store and update every 10th stays around
0.090 ms in this harness (row objects are replaced). The store’s win is
in-place mutation of deep fields, where the list is never re-diffed at all.
The same page has Lit, Stencil, Solid, Svelte, Vue and React on the same ops.
- State that scales — making a changed row wake only its own cell
- Components — putting a list inside a custom element