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:

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 an if branch naturally rewires its dependency set on the next recompute. A derivation written as g => g(flag) ? g(a) : g(b) tracks {flag, a} while flag is 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

CallWhat 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

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

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.