Skip to content

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. insertBefore on 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.
each, liverunning the real bundleEdit in playground
todos.js
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)}>&times;</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.

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)
// ^^^^^^^^^^^^^^^^ live

Writing ${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.

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.

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 reference
each(items, render, (item) => item.id) // identity: the id

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

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 persist
html`<ul>${() => each(() => rows(), renderRow)}</ul>` // rebuilt on every run — don't

The source is already a function, so there is nothing reactive to gain from the outer thunk — pass each(...) directly as the child value.

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>`

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>`

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>`)

From the benchmark, against hand-written keyed DOM, JS and DOM mutation only:

operationvanilla.mapeach
append 1,000 to 10,0002.5010.06.20
update every 10th row0.0300.3850.090
swap 2 rows<0.011.370.110
remove a row<0.010.3650.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.