Deep dive
How it works
Parts:
- signal — source node holding a value; a write is the only input
- memo (computed) — cached derived value with
sourcesandobservers - 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).
- Track.
get(id)inside a running memo/effect appends an edge both ways using the current observer. Outside any computation it just reads. - Push (flag only).
setstores the value and marks direct observers DIRTY, their descendants CHECK. Effects reached are queued once. No user code runs here. - 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.
- Glitch-freedom. In
a -> b, a -> c, d = b + c,dis only CHECK after the write. Readingdfirst bringsbandcup to date, then runsdonce. No sort is stored; the pull order is the sort. (Preact and Vue use edge version numbers instead of colors.) - Batching.
batchdefers the flush until the outermost call ends: N writes, one effect run. - Ownership. Effects created inside a computation are owned by it and disposed when it re-runs or is disposed (Solid
createRoot/onCleanup, LeptosOwner); else edges leak.disposebelow 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 twoVecheaps, 8 bytes per edge side. - Measured here (Apple M4 Pro, release, the code above,
Instantover 1e6 sets): signal -> 1 effect about 60 ns perset; the 4-node diamond about 290 ns perset; a 1000-deep memo chain about 90 ns per memo run. One signal with 1000 effects: about 320-380 ns per effect run, becauseunlinkdoesretainon 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,
Vecedge 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
batchdocs) — 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.
untrackedreads — 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.Computedand a low-levelSignal.subtle.Watcher; no built-ineffect. - 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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| reactive_graph | Rust | leptos-rs/leptos | cargo add reactive_graph | — | — | — | — | runtime behind Leptos; owner-based disposal |
| @preact/signals-core | TypeScript | preactjs/signals | npm install @preact/signals-core | — | — | — | — | version-based; batch, untracked |
| solid-js | TypeScript | solidjs/solid | npm install solid-js | — | — | — | — | signals, ownership tree, compiled JSX |
| @vue/reactivity | TypeScript | vuejs/core | npm install @vue/reactivity | — | — | — | — | Vue 3 reactivity, standalone |
| alien-signals | TypeScript | stackblitz/alien-signals | npm 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