Skip to content

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 computation
count(5); // 5 — write
count.set(6); // the same thing, when you prefer it explicit
count.update((n) => n + 1); // 7 — write from the current value
count.peek(); // 7 — read without subscribing

Reading inside an effect or a computed creates a dependency. Writing wakes the things that depend on it, and nothing else.

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 now
area(); // 12 — cached
width(5);
area(); // 15 — recomputed on read, not on write
computed, liverunning the real bundleEdit in playground
signals.js
import { 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>`;
},
});

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
});

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())); // 12
a(2); // 23 — logged once, not twice

A 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())); // true
n(4); // silent — still true
n(5); // false

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

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 three

Read without creating a dependency:

import { untrack } from '@alacris/core';
effect(() => {
send(payload(), untrack(() => sessionId())); // re-runs for payload only
});

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 stop

Inside a component you rarely call root yourself — define wraps setup in one and disposes it when the element is removed.

@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';