Signals
A signal is a function that holds a value and remembers who read it.
import { signal } from '@alacris/core';
const count = signal(0);
count(); // 0 — read, and subscribe if inside a computationcount(5); // 5 — writecount.set(6); // the same thing, when you prefer it explicitcount.update((n) => n + 1); // 7 — write from the current valuecount.peek(); // 7 — read without subscribingReading inside an effect or a computed creates a dependency. Writing wakes the things that depend on it, and nothing else.
computed
Section titled “computed”A derived value. It is lazy — it does not recompute until something reads it — and memoised, so repeated reads are free.
import { signal, computed } from '@alacris/core';
const width = signal(4);const height = signal(3);const area = computed(() => width() * height());
area(); // 12 — computed nowarea(); // 12 — cachedwidth(5);area(); // 15 — recomputed on read, not on writeimport { define, html, signal, computed } from '@alacris/core';
define('demo-signals', { styles: ` :host { display: grid; gap: .6rem; font: inherit } .row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap } input { font: inherit; width: 6rem; padding: .25rem .5rem; border-radius: 6px; border: 1px solid currentColor; background: transparent; color: inherit } code { font-family: ui-monospace, monospace } .out { opacity: .8 } `, setup() { const width = signal(4); const height = signal(3);
// Lazy and memoised: this only recomputes when width or height changes, // and only if something is actually reading it. const area = computed(() => width() * height()); const label = computed(() => (area() > 20 ? 'large' : 'small'));
const num = (sig) => (e) => sig(Number(e.target.value) || 0);
return html` <div class="row"> <label>w <input type="number" .value=${width} @input=${num(width)} /></label> <label>h <input type="number" .value=${height} @input=${num(height)} /></label> </div> <div class="out"> area = <code>${area}</code> · <code>${label}</code> </div>`; },});effect
Section titled “effect”Runs immediately, then again whenever a value it read changes. It returns a disposer, and the function can return its own cleanup.
import { signal, effect } from '@alacris/core';
const query = signal('');
const stop = effect(() => { const controller = new AbortController(); fetch(`/search?q=${query()}`, { signal: controller.signal }); return () => controller.abort(); // before the next run, and on dispose});
stop();The cleanup runs before each re-run as well as on disposal, which is what makes subscriptions, timers and listeners safe to create inside one:
effect(() => { const id = setInterval(tick, delay()); return () => clearInterval(id); // the old interval is always cleared});Updates are glitch-free
Section titled “Updates are glitch-free”Two properties matter, and both are tested.
A diamond runs its effect once. If two computed values depend on the same signal and an effect depends on both, the effect runs a single time per change, never with one branch stale:
const a = signal(1);const b = computed(() => a() + 1);const c = computed(() => a() * 10);
effect(() => console.log(b() + c())); // 12a(2); // 23 — logged once, not twiceA computed that lands on the same value stops there. Nothing downstream is woken:
const n = signal(2);const isEven = computed(() => n() % 2 === 0);
effect(() => console.log(isEven())); // truen(4); // silent — still truen(5); // falseThat second property is what makes fine-grained rendering practical: a signal can change constantly, and the DOM only hears about it when a value the DOM actually shows has changed.
Dependencies are dynamic
Section titled “Dependencies are dynamic”They are recollected on every run, so a branch that stops executing stops subscribing:
effect(() => { if (enabled()) console.log(value());});While enabled() is false, writing value does nothing at all.
Writes apply synchronously. Group a burst so dependents run once at the end:
import { batch } from '@alacris/core';
batch(() => { first('Ada'); last('Lovelace'); age(36);}); // one pass, not threeuntrack
Section titled “untrack”Read without creating a dependency:
import { untrack } from '@alacris/core';
effect(() => { send(payload(), untrack(() => sessionId())); // re-runs for payload only});root and onCleanup
Section titled “root and onCleanup”root creates an ownership scope. Disposing it disposes every effect created
inside — which is exactly what a component does when it is removed.
import { root, onCleanup } from '@alacris/core';
const dispose = root(() => { effect(() => { /* … */ }); effect(() => { /* … */ }); onCleanup(() => console.log('scope torn down'));});
dispose(); // both effects stopInside a component you rarely call root yourself — define wraps setup in
one and disposes it when the element is removed.
Using signals without the DOM
Section titled “Using signals without the DOM”@alacris/core/signal is the reactive core on its own, 1.03 kB, with no DOM
dependency — useful in a worker, on a server, or in tests.
import { signal, computed, effect } from '@alacris/core/signal';- Templates — putting signals into the DOM
- State that scales — when one signal per value stops being enough