Tutorial · Part 4

Statecharts: lifecycle as a value

So far this tutorial has treated the graph as a synchronous transactional store: inputs, derived nodes, and commits. Real applications fetch things. They wait for servers. They get racey answers back in the wrong order. Part 4 is about how causl models that — not as a pile of booleans and string flags, but as a statechart whose transitions you reduce with one pure function: evaluateStatechart from @causl/sync.

By the end of this page you will have built a small resource fleet, understand the five-arm ResourceState domain, know which transitions are legal vs. forbidden, and recognise the stale-async race rows (S-1, S-2) that the staleness guard exists to neutralise.

1. Why a statechart, not a status flag

The naïve approach to async state is well-known: keep a loading: boolean, an error: Error | null, and a data: T | undefined next to each other in the same object, then write conditionals to decode what they mean. Adopters of that pattern eventually discover the same defects: loading=true with non-null data (a refetch in flight), loading=false with neither data nor error (an unreachable case that happens anyway), and the worst kind — an old data staying authoritative after a newer commit invalidated it.

A statechart cuts the failure modes off at the type level. Each named state carries exactly the fields it can prove are present, and transitions between states are an exhaustive switch. There is no "loaded but also loading and also errored" — those are different tags of a discriminated union, and the type system refuses to let you read .value from state: 'idle'.

@causl/sync's ResourceFleet region of the composite chart names five states: idle, loading, loaded, stale, errored. Every transition between them is one engine commit, and the decision function — which transitions are legal from which sources — is the pure reducer the sync package exposes through evaluateStatechart.

2. Registering a resource

The resource() binder allocates one InputNode<ResourceState<T>> in the idle state and returns a handle with imperative triggers — fetch, invalidate, fail. Each trigger calls the reducer to compute the next state, then commits exactly once.

import { createCausl } from '@causl/core'; import { resource } from '@causl/sync'; interface User { id: number; name: string } const graph = createCausl(); const user = resource<User>(graph, 'user:42', { loader: async (origin) => { const res = await fetch(`/api/users/42?t=${origin}`); return res.json() as Promise<User>; }, }); graph.read(user.node); // => { state: 'idle' } await user.fetch(); graph.read(user.node); // => { state: 'loaded', value: { id: 42, name: ... }, origin: 1, loadedAt: 2 }

Two things to notice. First, the loader receives the originating GraphTime as its argument — that's how the staleness guard later decides whether a slow resolve is still authoritative. Second, the value carried on the node is a tagged union; you never have to ask "is it loaded yet?" by checking for the presence of value. The state tag answers exhaustively.

3. The five-arm domain

Here is the full ResourceState<T> type as it appears in @causl/sync:

type ResourceState<T> = | { state: 'idle' } | { state: 'loading'; origin: GraphTime; promise: Promise<unknown> } | { state: 'loaded'; value: T; origin: GraphTime; loadedAt: GraphTime } | { state: 'stale'; value: T; origin: GraphTime; loadedAt: GraphTime } | { state: 'errored'; error: unknown; origin: GraphTime; erroredAt: GraphTime };

Every "X may or may not have Y" optional is surfaced as a tag instead of being smuggled in as undefined. Reading .value on the idle tag is a compile-time error. Reading .error on the loaded tag is a compile-time error. The chart's edges are the only legal ways to move between tags.

Detailed explanation: why a Promise rides on loading. The promise field on the loading arm is reference-stable across reads of the same loading episode (Theorem 3 in docs/semantics-async.md). React's Suspense renderer awaits exactly that Promise; carrying it on the state means every render that subscribes to the node sees the same Promise identity, which is what SuspenseList and startTransition need to coalesce correctly.

4. The transition table

The ResourceFleet region has six edges. Every other source-state / event combination is either a chart-named no-op (no commit, no GraphTime advance) or a forbidden transition that surfaces as ForbiddenResourceTransitionError.

FromEventToTrigger
anyfetch-startloadinguser.fetch()
loadingfetch-resolve (clean)loadedloader promise resolves, no later commits
loadingfetch-resolve (guarded)staleloader resolves after a later commit advanced GraphTime
loadingfetch-rejecterroredloader promise rejects
loadedinvalidatestaleuser.invalidate()
loading | loadedfailerroreduser.fail(error) from host

The pattern is worth internalising: every observable transition routes through graph.commit — Theorem 2 from the async semantics. There is no out-of-band mutation, no "set the resource to errored" that bypasses the chart. Even fail() goes through the same reducer that fetch-reject goes through.

5. The reducer is pure

The decision logic — given a current state, an event, and a GraphTime, what is the next state? — is one pure function. The wiring shell (in resource.ts) reads the current node value, calls the reducer, and commits the result. The reducer never reads from the graph, never calls graph.now, never closures over engine handles. Because the reducer is pure and substrate-independent, the same chart behaviour holds no matter which engine sits underneath — so the rest of this tutorial runs on the default pure-TS engine you get from createCausl(), and nothing here requires anything more.

// Conceptual signature exposed via @causl/sync's BackendEngine seam. // The structurally-equivalent core implementation lives at // packages/core/src/statechart-evaluator.ts. function evaluateStatechart(input: StatechartInput): StatechartResult; // where: type StatechartInput = | { region: 'resource'; state: ResourceStateShape; event: ResourceEvent; time: GraphTime; id: NodeId } | { region: 'conflict'; state: ConflictStateShape; event: ConflictEvent; time: GraphTime; id: NodeId }; type StatechartResult = | { kind: 'ok'; next: ResourceStateShape | ConflictRecord } | { kind: 'forbidden'; reason: ForbiddenStatechartTransition };

You will not normally call evaluateStatechart by hand — the resource() and createConflictRegistry() binders do that for you. But knowing the seam is there is what unlocks the next two patterns: guards and race-row analysis.

6. Guards: the staleness check

A guard in statechart vocabulary is a Boolean condition attached to an edge. The chart has the edge available, but the guard decides whether to take it. The ResourceFleet region uses exactly one guard, and it neutralises the most common async hazard: the stale-resolve race.

Suppose two fetches happen back-to-back. The first goes out at GraphTime = 5, the second at GraphTime = 6. The second is faster — it resolves first, lands at GraphTime = 7 with the fresh value. Then the slower first fetch finally resolves at GraphTime = 8, carrying yesterday's answer.

Without a guard, the older value clobbers the newer one. With the guard, the reducer checks time > event.loadingAt and routes the late resolve to stale instead of loaded — the value is preserved (so callers that prefer stale-while-revalidate UX can show it), but the tag signals that the engine no longer considers it authoritative for origin.

// Two overlapping fetches, last-issued-wins via the staleness guard. const u = resource<User>(graph, 'user:42', { loader: (origin) => slowFetch(origin), }); const first = u.fetch(); // origin = 1, loadingAt = 2 graph.commit('bump', () => {/* something else */}); // now = 3 const second = u.fetch(); // origin = 3, loadingAt = 4 await Promise.allSettled([first, second]); // If the FIRST loader resolves after the SECOND, it arrives at // graph.now > loadingAt and the reducer routes Loading → Stale. // The second resolve, arriving on a fresh loading episode at // graph.now === loadingAt, routes Loading → Loaded as normal.

The stalenessGuard option defaults to true. Setting it to false opts into last-writer-wins semantics — late resolves clobber. The @causl/checker static lint flags this case explicitly because it is almost always a bug; the explicit opt-out exists for the rare adapter that needs it (e.g. a write-through cache with monotonic versioning of its own).

7. Race rows S-1 and S-2

The SPEC.async.md §9.1 race catalogue names two stale-async race rows the resource statechart handles:

S-1 — stale-resolve race. A Loading resource resolves after another commit advanced GraphTime past the loader's origin. The guard routes Loading → Stale; the late value is carried, not authoritative.
S-2 — interleaved-refetch race. Two overlapping fetch() calls on the same resource. The chart's * → Loading edge is unconditional from any source, so the second fetch always wins the loading slot. The first loader's eventual resolve hits the guard and lands as Stale; the second resolve lands as Loaded normally.

The reducer treats both rows the same way — the guard is one comparison. The race-class audit (docs/race-class-audit.md) lists S-1 / S-2 as the rows the staleness guard exists to neutralise, and pins the witness tests to theorem-4-graphtime-monotonicity.test.ts and the property suite evaluate-statechart-agreement.property.test.ts.

A third row — S-3, the dispose-during-fetch race — is not handled by the chart itself; it lands one level up in the InteractionMode orthogonal region (a fetch in flight when the resource is disposed; the eventual resolve writes to a node that no longer exists). The chart-conformance test in @causl/sync covers that one through Phase H of the engine, not the reducer.

8. Forbidden transitions

A forbidden transition is what happens when the host tries to drive an edge the chart does not have. The reducer returns { kind: 'forbidden', reason: { region, from, to, id } }; the wiring shell wraps that in ForbiddenResourceTransitionError and throws.

import { ForbiddenResourceTransitionError } from '@causl/sync'; const u = resource<User>(graph, 'user:42', { loader: fetchUser }); try { u.fail(new Error('server said no')); // u is in 'idle' } catch (e) { if (e instanceof ForbiddenResourceTransitionError) { console.log(e.from); // 'idle' console.log(e.to); // 'errored' // The chart has no idle → errored edge. fail() is only legal // from 'loading' or 'loaded'. The error tells you exactly why // the transition was refused. } }

The forbidden-error class is a real safety net. It catches the "out-of-band websocket pushed an error for a resource we never fetched" mistake at the boundary — without it, the application would write the errored tag to an idle node and lose the precondition that errored.origin reflects an actual fetch. That precondition is what makes whyUpdated()'s per-edge classification work downstream.

9. The other region: ConflictRegistry

evaluateStatechart dispatches on a region tag. The other implemented region is ConflictRegistry — a derived overlay onto an application-supplied open-set computation, with status arms open, resolved, ignored, superseded.

import { createConflictRegistry } from '@causl/sync'; const registry = createConflictRegistry(graph, { openSet: g => computeOpenConflicts(g), }); registry.resolve(conflictId, { pick: 'theirs' }); // transitions open → resolved on this conflict, leaves others untouched registry.ignore(otherConflictId); // open → ignored registry.supersede(thirdId, { bySupersedingId: fourthId }); // open → superseded; the chart has no edges out of any terminal

The chart structure is similar — one source (open), three terminals, no edges back. The reducer rejects every resolve / ignore / supersede against a terminal as ForbiddenConflictTransitionError. Same machinery, different region. evaluateStatechart's region dispatch is what keeps the two charts disjoint while sharing the seam.

10. Subscribing to lifecycle transitions

Because the resource's state lives on a regular InputNode, every engine primitive composes unchanged. You can subscribe, you can derive over it, you can read at past GraphTime for time-travel debugging, and you can decode the transition reason from a commit with whyUpdated().

import { whyUpdated } from '@causl/sync'; graph.subscribe(user.node, (next, prev, commit) => { const reason = whyUpdated(commit, prev, next); // reason: 'fetch-start' | 'fetch-resolve-loaded' | 'fetch-resolve-stale' // | 'fetch-reject' | 'invalidate' | 'fail' | 'evicted' console.log(`user:42: ${prev.state} → ${next.state} (${reason})`); });

The seven ResourceUpdateReason arms are closed — adding a new reason is a SPEC-level change because every downstream consumer (devtools, persistence, the React adapter) exhaustively switches on the tag. That closedness is the same discipline as the chart itself: a finite, named set of legal transitions.

11. When statecharts are the right tool — and when they aren't

Statecharts shine when the lifecycle has a small, named set of states whose transitions you want to enforce statically and observe per-edge. Resource fetching, conflict resolution, transaction phases, wizard flows — anywhere a string-flag would otherwise grow conditionals.

They are not the right tool for high-cardinality data with no lifecycle: a 100k-row table doesn't need a chart per row. The graph.input/graph.derived primitives from Parts 2 and 3 carry that load directly. The chart is for shape, not volume.

12. What you built

Part 5 turns from one resource to many: a registry of conflicts, a fleet of resources, and the orthogonal-region composition that lets the chart describe both at once.

Further reading