Frontend Labs
⋯

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:

  1. Push: a write bumps the signal's _version, bumps a globalVersion, and notifies its dependents. Notifying only sets flags (NOTIFIED/OUTDATED). Nothing is computed yet.
  2. 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. globalVersion answers "has anything changed since I last looked?" with one integer comparison. Per-source _version numbers answer "did my inputs change?" without comparing values.
  • Linked lists instead of Sets. Dependencies are stored in Node objects linked both ways, so they can be reused between runs without allocating. (§53's Proxy store used Sets, 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.

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36