Anatomy of a Reactive Engine: How Reflex Quant Is Built
Every reactive library starts simple. Then dependencies change at runtime, computations throw, effects dispose themselves, writes happen mid-run, and graphs grow to thousands of nodes. Each case tempts one more flag, one more index, one more queue.
Reflex Quant asks the opposite question: fix the semantics first, then keep only the machinery they force. This is what was left.
Three layers, one owner per fact
Kernel
What depends on what, what changed, what must re-check. The only source of truth about dependencies.Host
Delivers work to effects: queue, batching, cleanup order. Never decides whether a dependency exists.Surface
The typed API. A handle is the node, with a type brand: no wrapper object.A UI framework can replace the host (its own phases, ownership, async) without touching the graph.
Two objects. One edge lives in two lists.
interface Node { flags; value; ver; fn; deps; subs; lastSub; slot; gen } // 9 fields
interface Edge { from; to; ver; gen; prev; next; prevSub; nextSub } // 8 fieldsEach edge also carries ver (which version of the source the consumer saw) and gen (whether a successful run confirmed it). Facts that other runtimes keep in separate tables (read history, dirty sets, rollback logs) live on the relation itself.
Exact tracking: depend on what you read, not what you wrote
full and first are edges. Writing last wakes nothing.The branch flips: last becomes an edge. Flip back and it is dropped again.import { computed, effect, get, set, signal } from "reflex-quant";
const full = signal(false);
const first = signal("Ada");
const last = signal("Lovelace");
const name = computed(() => (get(full) ? `${get(first)} ${get(last)}` : get(first)));
effect(() => console.log(get(name))); // "Ada" name reads full, first
set(last, "Byron"); // nothing runs: last was not read
set(full, true); // "Ada Byron" name now reads last too
set(full, false); // "Ada" last is dropped again
set(last, "King"); // nothing runsHarder case: the same sources, read in a different order.
Structure counter reorder.rotate-256: 256 sources rotated every pass. Quant moves existing edges; it does not rebuild them. More on dynamic tracking.
How a read finds its edge, cheapest proof first
- Cursor hit. The next edge in the list is this source: advance.O(1), no lookup
- Repeat. Same source as the last read.O(1)
- Slot. This pass already indexed the sources:
source.slotpoints at the edge.O(1) - No subscribers. The source has none, so the edge cannot exist: create it.O(1)
- Bounded scan of
SCAN = 8edges: found, proven absent, or unknown.≤ 8 steps - Index once, then every later miss in the pass is a slot lookup.one linear pass
"Not found in 8" never means "absent". The scan is a price limit, not a precision limit, so tracking never turns quadratic when hundreds of reads are shuffled.
State proven by structure, not stored
// "this pass is indexed": the consumer owns its cursor's slot
cursor !== null && cursor.from.slot === cursor
// "this pass is marked": unread edges form one contiguous suffix
next !== null && next.ver === UNREADIn 1.1.2 two mode flags were deleted: the invariants on edge order and slot ownership already prove them.
Push dirty, pull values
A write only marks. A read (or an effect's turn) pulls: validate sources in read order, stop at the first changed one, run. If the new result is Object.is-equal to the old one, its version does not move and everything below stays clean: cutoff.
import { computed, effect, get, set, signal } from "reflex-quant";
const n = signal(1);
const parity = computed(() => (get(n) % 2 === 0 ? "even" : "odd"));
effect(() => console.log("effect:", get(parity)));
set(n, 3); // parity recomputes to "odd" again: cutoff, the effect stays quiet
set(n, 4); // "even": the effect runsVersions, not values
| Step | A.value | A.ver | edge A→B .ver |
|---|---|---|---|
| B reads A | 1 | 10 | 10 |
set(A, 2) | 2 | 11 | 10 |
set(A, 1) | 1 | 12 | 10 ≠ 12 → re-run |
The value came back (A→B→A), but the change is not lost. One counter gives both properties: no lost changes, and cutoff for unchanged results.
import { computed, get, set, signal } from "reflex-quant";
const A = signal(1);
let runs = 0;
const B = computed(() => (runs++, get(A) * 10));
get(B);
set(A, 2);
set(A, 1); // back to 1, but the version moved twice
console.log(get(B), "runs:", runs); // 10 runs: 2Effects check more than computeds
A computed may run as soon as one source changed. An effect first validates all its committed sources, because its next step, running the old cleanup, is irreversible:
- validate every committed source (a throw stops here, cleanup intact);
- still DIRTY? if no result changed, nothing runs;
- run the previous cleanup, untracked;
- run the effect; its return value is the next cleanup.
A write inside a run is not forgotten
import { computed, get, set, signal } from "reflex-quant";
const a = signal(1);
const c = computed(() => {
const first = get(a);
if (first === 1) set(a, 2);
return first + get(a);
});
console.log(get(c)); // 3
console.log(get(c)); // 4
console.log(get(c)); // 4The edge keeps the version of the first read, so the change made mid-run leaves a debt of one more run. Rereading a would otherwise erase the evidence.
Failure must not rewrite history
| Run | Reads | Outcome | Edges kept |
|---|---|---|---|
| 1 | A B C | ok | A B C committed |
| 2 | A D | throws | A B C + D tentative |
| 3 | A E | throws | A B C + E (D dropped) |
B and C belong to the last good run, so they stay. D or E may be what fixes the error, so a change there must still wake the node. No rollback log: an edge created during a run is stamped with the next generation, node.gen + 1. A successful run bumps node.gen once, which commits every new edge at once. When the 30-bit counter nears wrap-around, a rare linear pass renumbers the edges.
import { computed, get, set, signal } from "reflex-quant";
const ready = signal(false);
const c = computed(() => {
if (!get(ready)) throw new Error("not ready");
return "ready";
});
try { get(c); } catch (e) { console.log("threw:", e.message); }
set(ready, true); // the failed run's dependency still wakes it
console.log(get(c));One stack, three jobs
Push
Saves the next sibling to come back to after a deep branch.Pull
Saves the edge to climb back up; on a throw, marks the path for re-run.Tracking
Saves the slot a nested pass displaced, restored viaedge.from.Watch it on application code: the same snippet, run by Quant and by a naive run-on-read runtime.
stack[] holds edges: a sibling the push will come back to, or the way back up a pull. Edges in the graph are the live dependencies, so a branch switch adds and drops them. The naive runtime re-runs whatever is stale when read, nesting closures, and has no cutoff.All three save an Edge, so one array serves them all, and graph depth is bounded by memory instead of the JavaScript call stack. Storing only the edge (its source is edge.from) halved the stack peak in a nested-index test:
batch() is not a transaction
import { batch, effect, get, set, signal } from "reflex-quant";
const a = signal(0);
effect(() => console.log("effect sees", get(a)));
batch(() => {
set(a, 1);
set(a, 2);
console.log("inside batch", get(a)); // 2: writes commit immediately
}); // only the effect waited
console.log("after batch");No rollback, no isolation: batching defers effect delivery, nothing else. If one effect throws, the rest of the queue still runs and the first error reaches the caller.
What it costs
A single read or write is not cheaper than in alien-signals. Exact tracking has a price per operation; the payoff is the graph work that never happens.
Table view
| Scenario | Quant | Reflex | alien-signals |
|---|---|---|---|
reorder.rotate-256 | 1.00 | 0.83 | 6.67 |
reorder.reverse-8 | 1.00 | 0.83 | 5.88 |
long-reorder.256 | 1.00 | 0.83 | 5.88 |
edit.insert-one-256 | 1.00 | 0.94 | 4.76 |
shared-chaotic.64x32 | 1.00 | 1.28 | 4.55 |
value-sort.256x32 | 1.00 | 0.70 | 3.33 |
fan-in.1024 | 1.00 | 1.01 | 2.63 |
batch.writes-32 | 1.00 | 1.82 | 2.22 |
branch.switch-100 | 1.00 | 1.15 | 2.13 |
stable.wide-256 | 1.00 | 0.85 | 2.13 |
stable.wide-8 | 1.00 | 0.85 | 1.72 |
diamond.8x8 | 1.00 | 1.08 | 1.33 |
replace-half.256 | 1.00 | 1.10 | 1.30 |
fan-out.computeds-1000 | 1.00 | 0.85 | 1.16 |
fan-out.effects-1000 | 1.00 | 1.19 | 1.14 |
static.chain-100 | 1.00 | 0.92 | 1.08 |
lifecycle.mount-100 | 1.00 | 1.52 | 0.96 |
static.read-clean | 1.00 | 0.91 | 0.82 |
| Geometric mean | 1.00 | 1.01 | 2.20 |
Quant wins on batched writes, chaotic shared graphs and branch switches. It loses on reorders and sorts, where Reflex's specialised algorithms are faster: some of Reflex's machinery does buy speed, at the price of complexity. Full methodology: benchmarks.
How it is checked
Each program runs against an executable contract, checking graph integrity after every operation. An ablation shows that this implementation breaks without a mechanism, not that every possible design needs it. "Minimal" means the smallest known set for this contract, not a proven lower bound.
What Quant deliberately leaves out
Effect scopes, ownership trees, a render scheduler, async resources, transactions, automatic cleanup of unwatched computeds. Under the project's rule, a mechanism enters the core only if the contract or the asymptotics break without it. Frameworks can build these on top, because the kernel does not own them.
A runtime gets simpler not by solving fewer problems, but by storing each truth in one place. The price: correctness now rests on a few strong invariants instead of many synchronized fields.
Try it
npm install reflex-quantEvidence
- Source
- CONTRACT.md: invariants and structural bounds
- LOG.md: one counterexample per mechanism
- REPORT.md: research report and benchmark tables
- EDGE-CHURN.md: dependency edge churn study
- HISTORY.md: benchmarks across every CI run
Read next: what a signals runtime must guarantee, alien-signals vs Reflex Quant. Design notes: cost model, V8 feedback audit.
FAQ
How is a signals library implemented?
As a graph of nodes (signals, computeds, effects) and edges (dependencies). Writes push invalidation down the graph; reads pull fresh values up, re-running a computation only when a source it read has actually changed.
Why not just store dependencies in a Set?
A Set rebuilt on every run allocates on every execution and loses read order. Reusing the previous run's edges, in read order, makes stable and reordered reads allocation-free.
What happens when a computed throws?
In Reflex Quant it commits nothing, keeps the dependencies of its last successful run plus those of the failed attempt, and runs again on the next read.