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:
- A single user action invalidates dozens or hundreds of dependent values.
- Some derivations change which inputs they depend on as state changes.
- Async fetches return after the dependency they were launched against has moved.
- Wrong update ordering produces visible-but-inconsistent intermediate UI frames.
- A bug in the propagation is not a render bug — it is data corruption that ships to disk and to other users.
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 returnObject.is-distinct results when the engine re-materialises the value. Selectors and subscribers downstream ofreadmust use value equality, not identity equality, for change detection. The React adapter (useCausl) handles this for you by doing anObject.isdedup at the selector boundary; theuseCauslShallowhook 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.