Introduction to Causl

Causl is a transactional reactive engine for TypeScript applications whose state is not a tree of values but a live graph of facts whose derivations cascade. This page covers what causl is, the problem space it was built for, and the reactive-engine fundamentals you need to read the rest of the docs. If you want to evaluate causl against the alternatives, read Why Causl next.

What causl is

Causl is a small reactive engine, written in TypeScript and published as @causl/core, with first-party React bindings (@causl/react), async-resource lifecycle (@causl/sync), persistence (@causl/persistence), and a pair of Rust-backed CI gates (@causl/checker plus the bounded enumerator). The engine is built on a single denotational definition: a derived value's meaning is a function of its inputs at a given commit time — Behavior a = GraphTime → a. Every other behaviour (glitch-freedom, dynamic-dep cleanup, atomic commits, replay determinism) falls out of that definition rather than being asserted in prose.

The shape of the engine is anchored by fourteen commitments in SPEC §17.1 — each marked MECHANICAL (a CI gate exists) or DESIGN-DISCIPLINE (review policy enforces it). Commitment 6 names the worked example below as the gate for "the engine is real": until two inputs, one diamond derivation, one subscriber, and two commits produce exactly three observed propagations, no other phase begins.

import { createCausl } from '@causl/core' const graph = createCausl() const a = graph.input('a', 1) const b = graph.input('b', 2) const sum = graph.derived('sum', g => g(a) + g(b)) const sumPlusOne = graph.derived('sumPlusOne', g => g(sum) + 1) graph.subscribe(sumPlusOne, v => console.log(v)) // 4 graph.commit('bump-a', tx => tx.set(a, 10)) // 13 graph.commit('bump-both', tx => { tx.set(a, 100) tx.set(b, 200) }) // 301 — exactly one notification, not two

Four invariants live in that snippet: atomic commit (the 'bump-both' transaction fires the subscriber once, not twice), automatic dependency tracking (no manual useMemo keys), dynamic-dependency cleanup (if sum later stopped reading b, writes to b would stop scheduling sumPlusOne), and glitch-free diamond propagation (the subscriber never sees an intermediate sumPlusOne derived from a=100, b=2).

The problem space — state that is not a tree of values

The TypeScript and React ecosystem already has Redux, MobX, Jotai, Recoil, Zustand, Valtio, TanStack Query, XState, and a long tail of smaller stores. Each one is well-engineered for the slice it owns. Causl exists because none of them solves the shape of problem where:

Concrete systems shaped like this: spreadsheets, CMMS, BIM-style asset graphs, capital planning tools, scheduling and Gantt systems, scenario planners, configuration editors, dashboard composers, large operational consoles. If you have hand-written this and watched it fire in the wrong order —

useEffect(() => { setHighlights(deriveFromSelection(selection, plan)) }, [selection, plan]) useEffect(() => { setActiveAttachments(forSelection(selection)) }, [selection]) useEffect(() => { setPlanPings(forHighlights(highlights)) }, [highlights])

— you have hit the wall causl is built for. The wall is not "we need a better way to write that"; the wall is that the existing libraries each handle a piece, and stitching them produces a system whose correctness nobody can defend in code review.

Honest limit. If your state really is a tree of values — a flat object with maybe twenty fields and no cross-field derivations, or a cached HTTP response surface, or one giant form with validation — causl is over-engineered. Use Zustand, Jotai, TanStack Query, or React Hook Form. Causl is for the case where you need several of transactional commits, dependency tracking, dynamic deps, stale-async protection, and conflict records at the same time.

Reactive-engine fundamentals

Causl uses three runtime objects: an input node (a mutable cell), a derived node (a pure computation over other nodes), and a commit (the only place writes happen). The engine runs an eight-phase pipeline (Phases A–H in SPEC §5.1) on every commit: precondition checks, input writes, the input-layer fast path, a Kahn topological recompute, the frozen Commit assembly, commit-history rotation, subscriber dispatch, and transient dispose. The pipeline is the single contract everything else answers to; it is why a derived value defined as Behavior a = GraphTime → a can be glitch-free as a theorem.

1. Create the graph

import { createCausl } from '@causl/core' const graph = createCausl()

createCausl() returns the seven-method canonical API: graph.input, graph.derived, graph.commit, graph.read, graph.subscribe, graph.explain, plus the curated second-tier extensions (subscribeCommits, commitLog, snapshot, hydrate, readAt, simulate, exportModel, and the now getter). Every addition is justified one-by-one against the rule "name the unavoidable engine concept this lets us express, or take the cost of growing every reader's mental model."

2. Register inputs and derivations

const count = graph.input('count', 0) const doubled = graph.derived('doubled', g => g(count) * 2) const label = graph.derived('label', g => `count is ${g(count)} (×2 = ${g(doubled)})`)

Names are not cosmetic. They appear in graph.explain output, in commitLog entries, in devtools, and in the IR that causl-check reads — the static linter that runs in CI and refuses programs with cycles, missing dispose, cross-graph reads, or any of the other twelve static race classes catalogued in SPEC §9.1.

3. Commit to mutate

graph.commit('bump', tx => { tx.set(count, 42) }) console.log(graph.read(label)) // "count is 42 (×2 = 84)"

Writes happen only inside graph.commit(intent, tx => …). The transaction is atomic: if the callback throws, no writes land and observers do not fire. The intent string is the audit trail — it becomes the named entry in commitLog and the changedNodes set is the dispatch key for subscribers.

4. Subscribe to observe

const unsubscribe = graph.subscribe(label, next => { console.log('label changed:', next) }) graph.commit('bump-again', tx => tx.set(count, 7)) // "label changed: count is 7 (×2 = 14)" unsubscribe()

Subscribers fire in Phase G, after the Commit is frozen and the commit history rotated. They fire once per commit, regardless of how many derived nodes inside their dep set were recomputed. Subscriptions return an unsubscribe function; calling it twice is safe.

5. Read on demand

const current = graph.read(doubled) // 14
Detailed explanation — reference identity. graph.read() reference identity is not contractually guaranteed (SPEC §15.1). Two successive reads of a derived node returning the same value may return Object.is-distinct results when the engine re-materialises the value. Selectors and subscribers downstream of read must use value equality, not identity equality, for change detection. The React adapter (useCausl) handles this for you by doing an Object.is dedup at the selector boundary; the useCauslShallow hook handles the case of selectors that return fresh objects per render.

Where to go next

The Why Causl page compares causl to Redux/RTK, MobX, and Jotai at the level of detail an adopter needs to decide. The SPEC is the normative reference; docs/semantics.md walks the denotational shape; docs/lifecycle.md renders the composite statechart that governs every async / conflict / transaction / interaction lifecycle.

← Documentation Why Causl →