Your First Causl App
This page walks from an empty file to a working causl program in seven small steps. By the end you will have built a counter, derived a doubled value from it, run a transaction that mutates both inputs at once, and watched a subscriber fire exactly once per commit. The total is under thirty lines of TypeScript and exercises every method on the canonical seven-method API.
This tutorial assumes you have already followed the Installation page and have a project that can run TypeScript or JavaScript with ES modules. No bundler is required — Node 20+ is enough.
What we are building
A self-contained program with two input cells and one derived cell:
count— a writable integer, starting at0.step— a writable integer, starting at1. We will later move it to2.doubled— a derived cell whose value iscount * 2.
A subscriber attached to doubled prints the new value every time it changes. We will
use this to confirm two of causl's most important contracts: that a transaction is atomic
(a multi-write commit fires exactly one notification, not one per write), and that a derivation
that does not actually move its output does not wake its subscribers.
Detailed explanation: why nodes and not refs?
Many state libraries hand you a mutable reference and let you read and write it directly. Causl
separates registration from mutation: you register a named cell once (an input node) and
mutate it only through a transaction. The name is part of the contract — it is what shows up in
graph.explain(), in commit logs, in the static checker's IR. The handle the registration
returns is a typed token that proves the cell exists; values live in the graph, never on the token.
1. Create a graph
Everything in causl lives inside a graph. A graph is a self-contained universe: its own clock, its own commit log, its own set of nodes. You can have several graphs in the same process and they will not interfere with each other.
import { createCausl } from '@causl/core';
const graph = createCausl();
createCausl() takes an options object — used later for things like commit-log capacity,
observer error handlers, and the auto-adapt backend selector — but for a first app the defaults
are exactly what you want. The returned Graph exposes the seven methods we will use
below, plus a handful of inspection and lifecycle extensions covered in the
API reference.
2. Register input nodes
An input node is a writable cell. You give it a stable identifier and an initial value, and you get back a typed handle.
const count = graph.input('count', 0);
const step = graph.input('step', 1);
The id ('count', 'step') is the cell's name inside this graph. It must be
unique — registering a second node with the same id throws
DuplicateNodeError. Pick an id that means something to your application's information
model: 'count' is fine for a tutorial, but production code tends to use
colon-separated namespaces like 'cart:user-42:total' so the commit log reads as a
natural log of a real domain.
count and step are InputNode<number> handles. They carry
the value type at the type level — TypeScript will reject graph.read(count)
assignments to a string variable without a cast.
3. Register a derived node
A derived node is a computed cell. You give it an id and a pure function. The
function receives a tracked accessor — bind it to g (the single-letter form is
idiomatic for inline arrows; any name works) — and any node you pass to that accessor becomes
a dependency. Causl infers the dependency set from observed reads; you do not declare it up front.
const doubled = graph.derived('doubled', g => g(count) * g(step));
The closure has read count and step, so doubled now depends
on both. If a later commit changes count or step, the engine will
recompute doubled in topological order during Phase D of the commit pipeline.
If the recompute produces the same value as before, no subscriber is woken — causl's change
detection compares the new value to the previous one and stops the propagation right there.
Detailed explanation: conditional dependencies. Because the dependency set is inferred per evaluation, a derivation that switches inputs on anifbranch naturally rewires its dependency set on the next recompute. A derivation written asg => g(flag) ? g(a) : g(b)tracks{flag, a}whileflagis true and{flag, b}after it flips. No dependency-array bookkeeping is required.
4. Read the initial values
Reads always see the committed value at the current GraphTime. The first read
against a derived node forces the engine to evaluate the compute closure once; subsequent reads
are O(1) until something invalidates the cell.
console.log(graph.read(count)); // 0
console.log(graph.read(step)); // 1
console.log(graph.read(doubled)); // 0 (because 0 * 1)
Notice that doubled reports 0, not NaN or
undefined. Causl evaluates derivations lazily on first read and remembers the
result; you never have to "prime" the graph.
5. Subscribe to changes
A subscriber observes a single node. The observer fires once synchronously on registration (with the current value), then once per commit during which the node's value changed.
const unsubscribe = graph.subscribe(doubled, v => {
console.log('doubled =>', v);
});
// logs: doubled => 0
That initial synchronous fire is intentional — it gives subscribers their starting value
without an extra read call, which is the contract React adapters such as
useCauslNode rely on to render the first frame
correctly.
subscribe returns an Unsubscribe function. Calling it idempotently
detaches the observer. If you forget — for a process-lifetime graph in a Node script, for
instance — there is no leak; the graph holds the only reference, and the subscription dies
with the graph.
6. Commit a change
You cannot write to a cell outside a commit. Causl has no set on
input handles, no graph.write. The only way to advance state is
graph.commit(intent, run), which takes a human-readable label and a callback that
receives a transaction handle.
graph.commit('increment', tx => {
tx.set(count, 5);
});
// logs: doubled => 10
A few things just happened. The engine opened Phase A (precondition check), staged the write
in Phase B, propagated through the input-layer fast path in Phase C, ran the Kahn topological
recompute in Phase D, assembled a frozen Commit record in Phase E, appended it to
the commit log in Phase F, and finally dispatched the subscriber in Phase G — all atomically.
If the callback had thrown, none of those side effects would have landed. The graph's
now would still be at its previous tick, and the subscriber would not have fired.
The intent string ('increment') is opaque metadata that lands on the
Commit record and surfaces in devtools and replay logs. It is not used for any
kind of dispatch; it exists purely for human inspection.
7. Multi-write commits fire one notification
The most important property of commit is that all writes inside the callback land
at the same tick. The subscriber for doubled fires exactly once even when both of
its inputs move in the same transaction.
graph.commit('rescale', tx => {
tx.set(count, 10);
tx.set(step, 2);
});
// logs: doubled => 20 (NOT 10, then 20)
Compare this to a setter-per-cell library, where the subscriber would have seen an intermediate
value of 10 * 1 = 10 in between the two writes. Causl never publishes that
intermediate state — it does not exist as a fact about the graph's history.
8. No-op commits stay quiet
A commit that mutates a node back to its current value does not wake subscribers,
because doubled did not change. The engine compares each derived cell's new value
to its previous value with Object.is equality and stops dispatch on identity.
graph.commit('noop', tx => {
tx.set(count, 10); // already 10
tx.set(step, 2); // already 2
});
// nothing logged
This is what makes causl safe to call eagerly from event handlers and effects: writing the same value back is free at the subscriber layer. It costs the commit pipeline (the transaction still goes through Phases A–G) but it costs none of your downstream observers.
Putting it together
Here is the complete program. Copy it into a single file, run it with node (or
tsx / ts-node), and the output should match the comments line for line.
The expected console output is:
initial doubled: 0
doubled => 0
doubled => 10
doubled => 20
What you just used
| Call | What it does |
|---|---|
createCausl() | Builds an isolated graph with its own clock and commit log. |
graph.input(id, value) | Registers a writable cell. |
graph.derived(id, compute) | Registers a computed cell with auto-tracked dependencies. |
graph.read(node) | Reads the committed value at now. |
graph.subscribe(node, fn) | Observes a single node; fires once per commit that moves it. |
graph.commit(intent, run) | Opens a transaction; writes land atomically at the same tick. |
tx.set(node, value) | Stages an input write inside the transaction callback. |
Things to try
- Add a second derived node —
const quad = graph.derived('quad', g => g(doubled) * 2);— and subscribe to it. Confirm that a single commit still fires it exactly once. - Try to call
tx.setondoubled. The engine throwsNotAnInputNodeError— derived cells are not writable by design, because permitting the write would tear the equationdoubled = f(count, step). - Close a cycle. Register
const a = graph.derived('a', g => g(b))andconst b = graph.derived('b', g => g(a)). The first commit that touches either side throwsCycleErrorwith the cycle path named. - Capture the
txhandle in a variable and try to use it after the callback returns. The engine throwsStaleTxError— the handle is bounded to its commit.
What you did not need
No selectors, no memoisation hooks, no dependency arrays, no actions, no reducers, no
middleware, no providers, no store enhancers. Causl's surface area for application state is the
seven methods you just used. The rest of the API — simulate, explain,
commitLog, snapshot / hydrate, subscribeReads
— exists to power devtools, persistence, SSR, and adapters, and you can ignore all of it until a
specific need arises.
Where to go next
- Core Concepts — graphs, time, inputs, deriveds, and the commit pipeline at the conceptual level.
- Tutorial: Building a Todo App — the same primitives applied to a non-trivial example with React.
- Usage Guide: Transactions — the full
commitcontract, includingsimulateand error handling. - Usage Guide: Derived Nodes — conditional reads, equality semantics, and when not to derive.
- API Reference — every method, every option, every error class, generated from the source.
Where causl is the right tool — and where it isn't. Causl is built for state where the dependency graph is the interesting part: spreadsheets, dataflow editors, reactive forms with cross-field validation, live-updating dashboards. If your state is mostly flat — a list of records, a single object, no derived structure — a simpler store will serve you better. Reach for causl when you find yourself writing hand-rolled "if A changed, recompute B and C" plumbing and worrying about whether you called the observers in the right order.