Home › Graphics & UI › UI frameworks & reactivity
algorithm medium

Signals and fine-grained reactivity

Problem Systems that show derived values (screens, spreadsheets, notebooks, build graphs) must update them when inputs change; recomputing and diffing everything wastes time when state is large and changes often.

A dependency graph of signals, memos and effects where reads auto-subscribe, so a state change re-runs exactly the outputs that used it, with no tree diff.

O(affected nodes) per write; O(1) per subscribe/unsubscribe with indexed lists; dependencies re-tracked on every recompute
signalsreactivitydependency-trackingpush-pullglitch-freeui
Overview

A UI is a function of state. Instead of re-running the function and diffing the result (virtual DOM), record which reads happened where: while a computation runs, a global "current observer" is set, and every signal read subscribes it. A write then walks the recorded edges and re-runs only the memos and effects that actually read that signal, down to one text node. Updates are push-flagged, pull-evaluated, so a diamond never shows a mixed state.

Key facts

Use when

  • Few values change often inside a large, mostly static view (counters, cells, live data, forms).
  • You want derived state with automatic invalidation and no tree diff.
  • Any dependency graph where a few inputs change and many derived values stay valid: spreadsheet recalculation, reactive notebooks, incremental compilers and language servers.

Avoid when

Deep dive

How it works

Parts:

  • signal — source node holding a value; a write is the only input
  • memo (computed) — cached derived value with sources and observers
  • effect — leaf that performs a side effect, such as a DOM write
  • current observer — global set while a computation runs, so every read subscribes it
  • flush queue — effects reached by a write, run once per batch

Operations: track, push (flag), pull (evaluate), batch, dispose (steps below).

Each node has state (clean, check, dirty), sources (what it read last run) and observers (its subscriber list).

  1. Track. get(id) inside a running memo/effect appends an edge both ways using the current observer. Outside any computation it just reads.
  2. Push (flag only). set stores the value and marks direct observers DIRTY, their descendants CHECK. Effects reached are queued once. No user code runs here.
  3. Pull (evaluate). Flush refreshes each queued effect. A CHECK node refreshes its sources first, in order; if one actually changed, that marks the node DIRTY and the loop stops. A DIRTY node drops its old edges, re-runs with itself as observer, and, if its value differs, marks its observers DIRTY.
  4. Glitch-freedom. In a -> b, a -> c, d = b + c, d is only CHECK after the write. Reading d first brings b and c up to date, then runs d once. No sort is stored; the pull order is the sort. (Preact and Vue use edge version numbers instead of colors.)
  5. Batching. batch defers the flush until the outermost call ends: N writes, one effect run.
  6. Ownership. Effects created inside a computation are owned by it and disposed when it re-runs or is disposed (Solid createRoot/onCleanup, Leptos Owner); else edges leak. dispose below unlinks by hand.
a ──► b ──► d ──► effect       set(a): b,c DIRTY; d,effect CHECK
 └──► c ──┘                    flush: effect asks d; d asks b, c; d runs once

Code

Single-threaded, i64 values, nodes in an arena; Rc<dyn Fn> is cloned out so no RefCell borrow is held while user code runs.

use std::cell::{Cell, RefCell};
use std::rc::Rc;

const CLEAN: u8 = 0; // value is up to date
const CHECK: u8 = 1; // some ancestor may have changed: verify sources first
const DIRTY: u8 = 2; // a direct source changed: must recompute

type Id = usize;
type Func = Rc<dyn Fn(&Rt) -> i64>;

struct Node {
    value: i64,
    state: u8,
    f: Option<Func>,       // None = plain signal (or disposed)
    effect: bool,
    sources: Vec<Id>,      // who I read last run
    observers: Vec<Id>,    // who read me (subscriber list)
}

#[derive(Default)]
pub struct Rt {
    nodes: RefCell<Vec<Node>>,
    observer: Cell<Option<Id>>, // the global "current observer"
    queue: RefCell<Vec<Id>>,    // effects waiting to run
    depth: Cell<u32>,           // batch nesting
}

impl Rt {
    fn add(&self, value: i64, state: u8, f: Option<Func>, effect: bool) -> Id {
        let mut n = self.nodes.borrow_mut();
        n.push(Node { value, state, f, effect, sources: vec![], observers: vec![] });
        n.len() - 1
    }
    pub fn signal(&self, v: i64) -> Id { self.add(v, CLEAN, None, false) }
    pub fn memo(&self, f: impl Fn(&Rt) -> i64 + 'static) -> Id { self.add(0, DIRTY, Some(Rc::new(f)), false) }
    pub fn effect(&self, f: impl Fn(&Rt) + 'static) -> Id {
        let id = self.add(0, DIRTY, Some(Rc::new(move |rt| { f(rt); 0 })), true);
        self.refresh(id);
        id
    }
    /// Read a node; inside a running memo/effect this records the dependency.
    pub fn get(&self, id: Id) -> i64 {
        if let Some(o) = self.observer.get() {
            let mut n = self.nodes.borrow_mut();
            if !n[o].sources.contains(&id) { n[o].sources.push(id); n[id].observers.push(o); }
        }
        self.refresh(id);
        self.nodes.borrow()[id].value
    }
    pub fn set(&self, id: Id, v: i64) {
        if self.nodes.borrow()[id].value == v { return; }
        self.nodes.borrow_mut()[id].value = v;
        let obs = self.nodes.borrow()[id].observers.clone();
        for o in obs { self.mark(o, DIRTY); }
        self.flush();
    }
    pub fn batch(&self, f: impl FnOnce()) {
        self.depth.set(self.depth.get() + 1);
        f();
        self.depth.set(self.depth.get() - 1);
        self.flush();
    }
    pub fn dispose(&self, id: Id) { // unsubscribe from sources; never runs again
        self.unlink(id);
        let mut n = self.nodes.borrow_mut();
        n[id].f = None; n[id].state = CLEAN;
    }
    fn mark(&self, id: Id, state: u8) { // push: only flags, no user code runs
        let obs = {
            let mut n = self.nodes.borrow_mut();
            if n[id].state >= state { return; }
            if n[id].state == CLEAN && n[id].effect { self.queue.borrow_mut().push(id); }
            n[id].state = state;
            n[id].observers.clone()
        };
        for o in obs { self.mark(o, CHECK); }
    }
    fn flush(&self) {
        while self.depth.get() == 0 {
            let q: Vec<Id> = std::mem::take(&mut *self.queue.borrow_mut());
            if q.is_empty() { break; }
            for e in q { self.refresh(e); }
        }
    }
    fn unlink(&self, id: Id) {
        let mut n = self.nodes.borrow_mut();
        for s in std::mem::take(&mut n[id].sources) { n[s].observers.retain(|&o| o != id); }
    }
    fn refresh(&self, id: Id) { // pull: bring one node up to date
        if self.nodes.borrow()[id].state == CHECK {
            let srcs = self.nodes.borrow()[id].sources.clone();
            for s in srcs {
                self.refresh(s); // a changed source marks us DIRTY
                if self.nodes.borrow()[id].state == DIRTY { break; }
            }
        }
        let f = { let n = self.nodes.borrow(); if n[id].state == DIRTY { n[id].f.clone() } else { None } };
        if let Some(f) = f {
            self.unlink(id); // dependencies are re-tracked on every run
            let prev = self.observer.replace(Some(id));
            let v = f(self);
            self.observer.set(prev);
            let changed = { let mut n = self.nodes.borrow_mut(); let c = n[id].value != v; n[id].value = v; c };
            if changed { // observers in CHECK must now recompute; equal value stops propagation
                let obs = self.nodes.borrow()[id].observers.clone();
                let mut n = self.nodes.borrow_mut();
                for o in obs { n[o].state = DIRTY; }
            }
        }
        self.nodes.borrow_mut()[id].state = CLEAN;
    }
}

Test (compiled, run, asserts pass): diamond, batching, cutoff, dynamic dependencies, disposal.

fn main() {
    let rt = Rt::default();
    let a = rt.signal(1);
    let (runs_d, seen) = (Rc::new(Cell::new(0)), Rc::new(RefCell::new(vec![])));
    let b = rt.memo(move |rt| rt.get(a) + 1);
    let c = rt.memo(move |rt| rt.get(a) * 10);
    let rd = runs_d.clone();
    let d = rt.memo(move |rt| { rd.set(rd.get() + 1); rt.get(b) + rt.get(c) });
    let sn = seen.clone();
    rt.effect(move |rt| sn.borrow_mut().push(rt.get(d)));
    assert_eq!(*seen.borrow(), [12]);          // 2 + 10
    rt.set(a, 2);                              // diamond: d once, never 3+10 or 2+20 mixed
    assert_eq!(*seen.borrow(), [12, 23]);
    assert_eq!(runs_d.get(), 2);               // initial + one update
    rt.batch(|| { rt.set(a, 3); rt.set(a, 4); });
    assert_eq!(*seen.borrow(), [12, 23, 45]);  // one effect run for two writes
    assert_eq!(runs_d.get(), 3);
    // equality cutoff and dynamic dependencies
    let x = rt.signal(1); let y = rt.signal(100);
    let parity = rt.memo(move |rt| rt.get(x) % 2);
    let runs = Rc::new(Cell::new(0)); let r = runs.clone();
    let pick = rt.memo(move |rt| { r.set(r.get() + 1); if rt.get(parity) == 1 { rt.get(y) } else { -1 } });
    assert_eq!(rt.get(pick), 100);
    rt.set(x, 3);                              // parity unchanged: pick not recomputed
    assert_eq!(runs.get(), 1);
    rt.set(x, 4); assert_eq!(rt.get(pick), -1);
    rt.set(y, 7);                              // y no longer a dependency
    assert_eq!(runs.get(), 2);
    let sn2 = seen.clone();
    let e = rt.effect(move |rt| sn2.borrow_mut().push(rt.get(a) + 1000));
    rt.dispose(e);
    rt.set(a, 5);
    assert!(!seen.borrow().contains(&1005));
}

Complexity and constants

  • Write: O(subscribers) to flag, then O(recomputed nodes).
  • Memory: the node above is 80 bytes (measured size_of) plus two Vec heaps, 8 bytes per edge side.
  • Measured here (Apple M4 Pro, release, the code above, Instant over 1e6 sets): signal -> 1 effect about 60 ns per set; the 4-node diamond about 290 ns per set; a 1000-deep memo chain about 90 ns per memo run. One signal with 1000 effects: about 320-380 ns per effect run, because unlink does retain on a 1000-entry observer list. That is O(n^2) per write; production libraries avoid it with index back-pointers or linked lists.
  • Cost drivers: node and closure allocation, Vec edge churn on re-tracking, pointer chasing.

Efficiency

Cost: one set reaching one effect is order of 60 ns in this toy on M4 Pro (measured above), dominated by pointer chasing and Vec/Rc handling. The effect body (a DOM write) usually dwarfs it. Bound: dependent-load latency, then the DOM.

Levers:

  • Equality cutoff on memos — an unchanged derived value skips its whole subtree (Reactively article) — derived flags like isEmpty.
  • Batching writes — one effect run per flush (Preact, Solid batch docs) — multi-field updates.
  • Linked-list edges plus version counters — Vue 3.5 PR reports memory -56% (1426k to 631k) in its test, +118% to +185% for one ref feeding many effects — large graphs.
  • Lazy memos, unsubscribed when unobserved — hidden UI costs nothing (Vue 3.5 PR) — conditional views.
  • untracked reads — no edge created (Preact docs) — constants read inside effects.

Naive → standard → state of the art:

  • Re-render the whole component on any change, then diff · O(view size) per write
  • Eager push with per-write topological sort, or coloring as above · O(affected) per write, extra sort cost or glitch risk
  • Lazy pull with coloring or version counters, intrusive linked edges, pooled nodes (Preact, Vue 3.5, alien-signals) · O(affected) with low constant and memory per edge

Implementation notes

  • Template compilation turns each dynamic hole into one effect writing one node.
  • Store edges as intrusive lists or (vec, back-index) so unsubscribe is an O(1) swap-remove.
  • Dedupe reads per run with a run counter, not contains (O(sources), as above).
  • Cap re-entrant writes from effects; async code must restore the current observer across await.

Pitfalls

  • Reads outside a tracking scope (a component body that runs once) never update; copying the value out at setup freezes it.
  • Writes inside a memo break the flag-then-pull invariant; effects writing what they read loop forever.
  • Forgetting disposal leaves dead effects subscribed: leak plus ghost updates.

Variants and alternatives

In production

  • UI frameworks: SolidJS, Preact Signals, Vue 3, Angular signals, Svelte 5 runes, Leptos and Reactively use this model. The TC39 Signals proposal (Stage 1) defines Signal.State, Signal.Computed and a low-level Signal.subtle.Watcher; no built-in effect.
  • Spreadsheets: when a cell changes Excel marks the cells that depend on it for recalculation and keeps a calculation chain (docs).
  • Data notebooks: the Observable runtime defines reactive variables, akin to spreadsheet cells, with derived variables recomputed from the ones they use (code).
  • Incremental programs: Salsa is a Rust framework for incremental, on-demand programs (docs).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
reactive_graphRustleptos-rs/leptoscargo add reactive_graph————runtime behind Leptos; owner-based disposal
@preact/signals-coreTypeScriptpreactjs/signalsnpm install @preact/signals-core————version-based; batch, untracked
solid-jsTypeScriptsolidjs/solidnpm install solid-js————signals, ownership tree, compiled JSX
@vue/reactivityTypeScriptvuejs/corenpm install @vue/reactivity————Vue 3 reactivity, standalone
alien-signalsTypeScriptstackblitz/alien-signalsnpm install alien-signals————minimal push-pull core

Sources

  • TC39, Signals proposal, Stage 1. repo
  • Milo (modderme123), Super Charging Fine-Grained Reactive Performance, dev.to. article · code
  • Ryan Carniato, A Hands-on Introduction to Fine-Grained Reactivity, dev.to. article
  • Vue core, refactor(reactivity): efficient reactivity system, PR #10397. PR · docs
  • Preact, Signals guide. docs · SolidJS, Intro to reactivity. docs · onCleanup
  • Angular, Signals guide. docs · Svelte, What are runes. docs

Builds on

Used by

Systems (3):

Alternatives and related

virtual dom diffing · plannedDiff old and new element trees, patch the DOM with minimal operations
keyed list reconciliation · plannedMinimal DOM moves for keyed children using a longest increasing subsequence
positional memoization · plannedStore composition state by call-site position in a slot table (Jetpack Compose)
retained mode ui tree · plannedWidget, element and render-object trees with rebuild, relayout and repaint boundaries
ui template compilation · plannedCompile templates or JSX to direct DOM creation and update instructions with static hoisting
damage tracking · plannedTrack dirty rectangles so each frame repaints and recomposites only what changed