Tags: web-dev concept

Signals

Date: 2026-08-19


A value that knows who read it. Reading a signal inside a computation subscribes that computation; writing to it re-runs exactly those subscribers and nothing else. Roughly thirty lines of code, and it removed the need for a diffing algorithm.


A signal is a reactive container holding a value plus the set of computations that read it. Three primitives, everywhere they appear, whatever they’re named:

  • signal — a readable, writable value
  • computed (derived) — a value defined by a function of other signals, cached until one of them changes
  • effect — a computation run for its side effect, re-run when its dependencies change

The mechanism, in full

The entire trick is a module-level variable holding whatever is currently running:

let listener = null;                      // the computation currently executing
 
function signal(value) {
  const subscribers = new Set();
 
  return {
    get value() {
      if (listener) {
        subscribers.add(listener);               // ← reading = subscribing
        listener.deps.add(subscribers);          // remember it, so we can undo it
      }
      return value;
    },
    set value(next) {
      if (next === value) return;                // ← equality check stops the cascade
      value = next;
      [...subscribers].forEach(run => run());    // notify exactly the readers
    },
  };
}
 
function effect(fn) {
  const run = () => {
    run.deps.forEach(subs => subs.delete(run));  // drop last run's subscriptions
    run.deps.clear();                            // ← this is what makes branches work
    const prev = listener;
    listener = run;                              // announce ourselves while running
    try { fn(); } finally { listener = prev; }   // restore, so nesting works
  };
  run.deps = new Set();
  run();                                          // run once to discover dependencies
}
const count = signal(0);
effect(() => console.log('count is', count.value));   // logs 0, and subscribes
count.value = 1;                                       // logs 1 — nothing else ran

Dependencies are never declared. They’re discovered by running the function and noting what it touched — which is why they’re always correct, including branches. An effect reading b.value only when a.value is true stops depending on b the moment a flips, because the re-run drops every subscription before collecting them again. Dropping them is not optional: without those two lines the dependency set only ever grows, and the effect keeps firing for values it no longer reads.

Why this replaced diffing

If a text node’s content is produced by an effect, that effect is subscribed to precisely the signals it read. Change one, and the framework updates that node directly.

DIFFING                              SIGNALS
state changes                        state changes
  → re-run the component               → notify 1 subscriber
  → build a new tree                   → update 1 text node
  → compare it to the old one
  → patch the difference

Cost scales with the number of things that actually depend on the value, not with the size of the tree. That’s the whole argument, and it’s why the model won — see Reactivity and Virtual DOM.

The two problems every implementation must solve

Glitches. With naive push-notification, a computed value can be observed mid-update:

a = 1
b = computed(() => a * 2)          // 2
c = computed(() => a + b)          // 3

a = 2  →  notifies b and c in insertion order
          c recomputes first, reads the stale b  →  2 + 2 = 4     ← wrong, briefly
          b recomputes  →  4
          c recomputes  →  6                                      ← settles correct

The fix is to sort the dependency graph so nothing runs before its inputs — real implementations mark subscribers dirty and pull values lazily in dependency order rather than pushing eagerly. This is why you should never hand-roll signals for anything real; the toy above has this bug.

Cleanup. An effect that subscribes to something external must be able to undo that on re-run, or subscriptions accumulate for the life of the page — see Memory and Long Sessions.

Where it goes wrong in practice

Losing the subscription is the only common bug, and it’s silent. The getter is the subscription mechanism, so anything that reads the value once and stores the result outside a reactive context is dead:

const {value} = count;                 // a number. never updates again
const total = count.value * price;     // computed once, outside any effect

Nothing throws. The UI just stops responding — which reads as a rendering bug rather than a subscription one, and sends people looking in entirely the wrong place.

Signals are not application state. They’re a propagation mechanism. What belongs in one, and where the value should live, is Component State vs Application State.