Skip to content

State that scales

A signal holds one value. When that value is a large object, handing it a fresh copy tells the renderer only something changed, so everything reading it re-runs. A store tells it exactly which path changed.

import { store } from '@alacris/core/store';
const state = store({ rows: [], filter: '', selected: -1 });
state.rows.push({ id: 1, label: 'first', done: false });
state.rows[0].label = 'renamed'; // wakes the one text node showing it
state.rows.splice(3, 1); // structural: the list re-syncs

Read a path and the reading binding subscribes to that path. Write it and only those readers run. state.rows[3].label = 'x' does not re-diff the list, does not rebuild the row, and does not consult any other row.

Path-level updates, liverunning the real bundleEdit in playground
store.js
import { define, html, each } from '@alacris/core';
import { store, selector } from '@alacris/core/store';
define('demo-store', {
styles: `
:host { display: grid; gap: .6rem; font: inherit; max-width: 30rem }
table { border-collapse: collapse; width: 100% }
td { padding: .3rem .5rem; border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent) }
tr[data-sel] { background: color-mix(in srgb, currentColor 10%, transparent) }
.n { opacity: .5; width: 2rem; font-variant-numeric: tabular-nums }
button { font: inherit; padding: .3rem .65rem; border-radius: 6px; cursor: pointer;
border: 1px solid currentColor; background: transparent; color: inherit }
.row { display: flex; gap: .5rem; flex-wrap: wrap }
.paints { opacity: .7; font-size: .85rem }
`,
setup() {
const state = store({
rows: [
{ id: 1, label: 'alpha' },
{ id: 2, label: 'bravo' },
{ id: 3, label: 'charlie' },
],
selected: -1,
paints: 0,
});
// O(1) selection: only the row losing and the row gaining it re-run.
const isSelected = selector(() => state.selected);
// Mutating one path wakes only the bindings that read it.
const renameSecond = () => { state.rows[1].label += '!'; };
const addRow = () => {
const id = state.rows.length + 1;
state.rows.push({ id, label: 'row ' + id });
};
return html`
<table>
<tbody>
${each(
() => state.rows,
(row) => html`
<tr ?data-sel=${() => isSelected(row().id)}>
<td class="n">${() => row().id}</td>
<td>${() => { state.paints; return row().label; }}</td>
<td><button @click=${() => (state.selected = row().id)}>select</button></td>
</tr>`,
(row) => row.id
)}
</tbody>
</table>
<div class="row">
<button @click=${renameSecond}>rename row 2</button>
<button @click=${addRow}>add a row</button>
<button @click=${() => (state.selected = -1)}>clear selection</button>
</div>
<p class="paints">
Renaming row 2 writes to one text node. The list is not re-diffed, the
other rows are not consulted, and no row is rebuilt.
</p>`;
},
});

No setters, no immutable update helpers, no spread gymnastics:

state.filter = 'done';
state.rows[2].done = true;
state.rows.sort((a, b) => a.label.localeCompare(b.label));
delete state.draft;

Plain objects and arrays become reactive recursively. Date, Map, Set, class instances and DOM nodes are stored and returned untouched — the store does not try to proxy things that would break.

splice shifts every element after the cut, one assignment at a time. Left unbatched, an observer would run against a half-shifted array — with holes where elements have not landed yet. Alacris wraps the mutators so that cannot happen:

state.rows.splice(1, 2); // observers see one consistent update

push, pop, shift, unshift, splice, sort, reverse, fill and copyWithin all apply as a single update.

import { store, unwrap, update } from '@alacris/core/store';
JSON.stringify(state); // works — the proxy is transparent
const raw = unwrap(state.rows); // the underlying array, untracked
update(state, (d) => { // many mutations, one pass for readers
d.filter = '';
d.selected = -1;
d.rows.length = 0;
});

“Which row is selected?” is the classic accidental O(n). A thousand rows each comparing against selected means a thousand subscribers, so every selection change wakes every row.

import { selector } from '@alacris/core/store';
const isSelected = selector(() => state.selected);
html`<tr class=${() => (isSelected(row().id) ? 'danger' : '')}>`

A selector keeps one small signal per key it is asked about and flips exactly two of them — the row losing the match and the row gaining it. In the benchmark this takes selecting a row from 0.56 ms to under 0.01 ms, which is the floor.

This is the combination that makes large lists cheap. each handles structure, the store handles contents:

import { html, each } from '@alacris/core';
import { store } from '@alacris/core/store';
const state = store({ rows: [/* … */] });
html`<ul>
${each(
() => state.rows,
(row) => html`<li>${() => row().label}</li>`,
(row) => row.id
)}
</ul>`;
state.rows[3].label = 'changed'; // one text node. No list work at all.

each reads the array’s length and slots tracked — so a push or a reorder rebuilds — but diffs untracked, so a write deep inside one row never triggers a list re-sync.

Shape Reach for
A handful of independent values signal
A value derived from others computed
A large object mutated in place store
Structural churn on a big list each + signal
Deep, targeted updates on a big list each + store
“Which one is selected?” selector
  • Context — getting state to components that are far apart
  • Performance — the numbers behind all of this