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 itstate.rows.splice(3, 1); // structural: the list re-syncsRead 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.
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>`; },});It is a normal object
Section titled “It is a normal object”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.
Array mutators are atomic
Section titled “Array mutators are atomic”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 updatepush, pop, shift, unshift, splice, sort, reverse, fill and
copyWithin all apply as a single update.
unwrap and update
Section titled “unwrap and update”import { store, unwrap, update } from '@alacris/core/store';
JSON.stringify(state); // works — the proxy is transparentconst 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;});selector — O(1) instead of O(n)
Section titled “selector — O(1) instead of O(n)”“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.
Pairing with each
Section titled “Pairing with each”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.
When to use which
Section titled “When to use which”| 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