71. Signals from the inside: push dirtiness, pull values
Signals are the reactivity model of SolidJS, Preact Signals, Angular (since v16), Svelte 5's runes, Vue's refs and Qwik. TC39 has a Stage 1 proposal to standardize the primitive. A signal holds a value, a computed derives one, and an effect runs side effects. When a signal changes, exactly the computeds and effects that read it update. No virtual DOM diff is needed (§33).
The hard part is doing that glitch-free and lazily:
first ──► upper ───┐
└────► initial ─┴─► fullName ──► effect(render)
When first changes, a naive push system recomputes fullName as soon as
upper changes, while initial still has the old value. The effect then
renders an inconsistent "GRACE (A.)" for a moment. This is a glitch.
Preact Signals avoids it in two phases:
- Push: a write bumps the signal's
_version, bumps aglobalVersion, and notifies its dependents. Notifying only sets flags (NOTIFIED/OUTDATED). Nothing is computed yet. - Pull: when someone reads a computed (or a batched effect runs), it checks its sources' versions in order and recomputes only if one of them really changed.
// src/index.ts (@preact/signals-core 1.14.4) — writing a signal
set(this: Signal, value) {
if (value !== this._value) {
if (batchIteration > 100) throw new Error("Cycle detected");
recordBatchSnapshot(this);
this._value = value;
this._version++;
globalVersion++;
/**@__INLINE__*/ startBatch();
try {
for (let node = this._targets; node !== undefined; node = node._nextTarget) {
node._target._notify(); // flags only
}
} finally {
endBatch(); // effects run here, once
}
}
},
// src/index.ts — reading a computed
Computed.prototype._refresh = function () {
this._flags &= ~NOTIFIED;
if (this._flags & RUNNING) return false; // a cycle
// Subscribed, and nobody notified us: the value can't have changed.
if ((this._flags & (OUTDATED | TRACKING)) === TRACKING) return true;
this._flags &= ~OUTDATED;
if (this._globalVersion === globalVersion) return true; // nothing changed ANYWHERE since last check
this._globalVersion = globalVersion;
this._flags |= RUNNING;
if (this._version > 0 && !needsToRecompute(this)) { // sources' versions all unchanged?
this._flags &= ~RUNNING;
return true;
}
// … run this._fn() with evalContext = this; bump _version only if the value changed
};
Three details make it fast:
- Two version checks.
globalVersionanswers "has anything changed since I last looked?" with one integer comparison. Per-source_versionnumbers answer "did my inputs change?" without comparing values. - Linked lists instead of Sets. Dependencies are stored in
Nodeobjects linked both ways, so they can be reused between runs without allocating. (§53's Proxy store usedSets, which is simpler but makes more garbage.) - Lazy subscription. A computed subscribes to its sources only once it has a subscriber. An unused computed costs nothing and never runs.
Build it: glitch-free signals in 70 lines
let context = null, globalVersion = 0, batchDepth = 0;
const pendingEffects = new Set();
class Signal {
constructor(value) { this._value = value; this.version = 0; this.targets = new Set(); }
get value() {
if (context) { context.sources.set(this, this.version); this.targets.add(context); }
return this._value;
}
set value(v) {
if (Object.is(v, this._value)) return;
this._value = v; this.version++; globalVersion++;
batch(() => this.targets.forEach((t) => t.notify()));
}
refresh() { return true; }
}
class Computed extends Signal {
constructor(fn) { super(undefined); this.fn = fn; this.sources = new Map(); this.dirty = true; this.seenGlobal = -1; }
notify() { // PUSH: mark, pass it on, compute nothing
if (!this.dirty) { this.dirty = true; this.targets.forEach((t) => t.notify()); }
}
refresh() { // PULL: recompute only if a source's version moved
if (!this.dirty || this.seenGlobal === globalVersion) return true;
this.seenGlobal = globalVersion;
const changed = this.version === 0 ||
[...this.sources].some(([src, seen]) => (src.refresh(), src.version !== seen));
if (changed) {
for (const src of this.sources.keys()) src.targets.delete(this);
this.sources.clear();
const prev = context; context = this;
try {
const v = this.fn();
if (!Object.is(v, this._value) || this.version === 0) { this._value = v; this.version++; }
} finally { context = prev; }
}
this.dirty = false;
return true;
}
get value() { this.refresh(); return super.value; }
}
class Effect {
constructor(fn) { this.fn = fn; this.sources = new Map(); this.run(); }
notify() { pendingEffects.add(this); }
run() {
// skip if no source produced a new version (e.g. a computed recomputed to the same value)
if (this.sources.size && ![...this.sources].some(([src, seen]) => (src.refresh(), src.version !== seen))) return;
for (const src of this.sources.keys()) src.targets.delete(this);
this.sources.clear();
const prev = context; context = this;
try { this.fn(); } finally { context = prev; }
}
}
export function batch(fn) {
batchDepth++;
try { return fn(); }
finally {
if (--batchDepth === 0) {
while (pendingEffects.size) {
const effects = [...pendingEffects]; pendingEffects.clear();
effects.forEach((e) => e.run());
}
}
}
}
export const signal = (v) => new Signal(v);
export const computed = (fn) => new Computed(fn);
export const effect = (fn) => new Effect(fn);
const first = signal("Ada"), last = signal("Lovelace");
const upper = computed(() => first.value.toUpperCase());
const initial = computed(() => first.value[0]);
const fullName = computed(() => `${upper.value} (${initial.value}.) ${last.value}`);
const isLong = computed(() => fullName.value.length > 20);
effect(() => console.log("render:", fullName.value)); // render: ADA (A.) Lovelace
effect(() => console.log("isLong:", isLong.value)); // isLong: false
first.value = "Grace"; // render: GRACE (G.) Lovelace (never "GRACE (A.)"; isLong stays false, so its effect is skipped)
batch(() => { first.value = "Alan"; last.value = "Turing"; }); // render: ALAN (A.) Turing (two writes, one render)
first.value = "Alan"; // same value: nothing runs
The run printed exactly those lines. fullName computed 3 times in total:
once at start and once per real change.
Proxy store (§53) or signals? Proxies give you "just mutate objects"
ergonomics and deep reactivity, and pay for it with identity and
private-field problems and per-access traps. Signals are explicit
(.value), cheap, and easy to reason about. That's why frameworks with a
compiler (Svelte 5, Solid) put signals underneath and hide .value behind
syntax.