Common Patterns

This page collects recurring shapes that Causl applications grow toward. Each pattern is described with motivation first, then a small working example, then the caveats we wish someone had told us earlier. None of these are new primitives — every pattern resolves down to graph.input, graph.derived, graph.commit, graph.read, and graph.subscribe (the canonical seven). The point of naming them is that once you can spot the shape, you can reach for it directly instead of rediscovering it.

Patterns are independent. Skim the headings, jump to whichever one matches what you are trying to build today, and come back when you hit the next one.

1. Derived selectors

The single most common pattern in any Causl application is: do not read raw inputs from your UI. Read a derived node that selects, filters, or shapes the input into exactly what the consumer needs. This is the same advice Redux's deriving data with selectors page gives, but in Causl selectors compose into the graph itself, so the engine takes care of caching and change-tracking.

import { createCausl } from '@causl/core'; const graph = createCausl(); // Raw inputs — large, frequently mutated. const todos = graph.input<Todo[]>('todos', []); const filter = graph.input<'all' | 'active' | 'done'>('filter', 'all'); // Selector — the only thing your component reads. const visibleTodos = graph.derived('visibleTodos', g => { const items = g(todos); switch (g(filter)) { case 'active': return items.filter(t => !t.done); case 'done': return items.filter(t => t.done); case 'all': return items; } });

The payoff is that visibleTodos recomputes only when one of its two dependencies actually changes, and only fires its subscribers when its output changes (Phase G's Object.is equality cutoff). Sorting a list of 10,000 items on every keystroke is not a Causl problem; sorting it only when the underlying list changes is the idiomatic shape.

Detailed explanation. graph.read() reference identity is not contractually guaranteed (SPEC §15.1). Selectors that return the same array twice in a row may hand out a new array reference even when contents are equal. If a downstream consumer needs identity stability — for example, React's useMemo dependency arrays — wrap the selector with useCauslShallow from @causl/react or memoise explicitly inside the compute function.
Why single-letter params? Examples on this site write derived computes as g => g(node) rather than (get) => get(node), and commits as tx => tx.set(...) rather than (tx) => tx.set(...). The engine accepts any name — these are just function parameters — but the parens-less single-letter form is idiomatic JavaScript for inline arrows and shortens read sites noticeably: g(count) * g(step) versus get(count) * get(step). The boilerplate of the longer form is real; an arrow wrapper is unavoidable (the expression must be deferred to be re-runnable, so graph.derived('x', g(a) * g(b)) can't work in JS), but the single-letter alias is the practical floor. Use whatever names you find clearer in your own code — the convention is a style nudge, not a contract.

2. Lazy expansion

Not every conceptual node should be alive at startup. A spreadsheet with a million cells, a tree with deep subfolders, or a tab strip with hundreds of tabs all share the same concern: the user only ever interacts with a few at a time. The lazy-expansion pattern registers nodes on demand the first time they are referenced, and disposes them when the last consumer goes away.

In React, useCauslFamily from @causl/react bakes this discipline into a hook. The factory runs on the first mount of each key; subsequent mounts share the same node; the engine disposes the node (deferred through a microtask) when the refcount falls to zero.

import { useCauslFamily, useCausl } from '@causl/react'; import type { FamilyGraph } from '@causl/react'; function cellFactory(graph: FamilyGraph, key: string) { return graph.input<number>(`cell:${key}`, 0); } function Cell({ id }: { id: string }) { const node = useCauslFamily(id, cellFactory); const value = useCausl(g => g.read(node)); return <span>{value}</span>; }

Because the registration capability passed to a family factory is the narrowed FamilyGraph (only input and derived), a factory cannot accidentally read, commit, or subscribe. That is the compile-time gate that keeps the lifetime concern from leaking into the registration step.

Why microtask-deferred disposal? React's StrictMode double-mounts components in development; immediate disposal on the first unmount would force a re-registration on the very next mount, which would lose any value that had been written between the two. Microtask-deferred disposal lets a re-mount cancel the pending disposal and keep the node stable — the hook is StrictMode-safe by construction.

3. Dynamic dependencies

A derived node's dependency set is not declared statically. It is the set of nodes the compute function actually get()s during a particular evaluation. This is sometimes called dynamic dependency tracking and it is what lets you switch a selector across data sources without re-registering.

const useMetric = graph.input<'mean' | 'median'>('useMetric', 'mean'); const mean = graph.derived('mean', g => computeMean(g(samples))); const median = graph.derived('median', g => computeMedian(g(samples))); const summary = graph.derived('summary', g => { return g(useMetric) === 'mean' ? g(mean) : g(median); });

The first time summary evaluates with useMetric === 'mean' its dependency set is {useMetric, mean}. If the user flips the flag, the next recompute sees useMetric change, evaluates the else branch, and the dependency set becomes {useMetric, median}. The engine drops the now-stale mean edge during Phase D, so a subsequent write to samples that only affects mean does not trigger a recompute of summary. Branches you do not take cost nothing.

4. Optional inputs

An "optional input" is a cell whose value may be absent, and whose downstream selectors should degrade gracefully rather than crash. The idiomatic way to model optionality is a discriminated union — never a bare T | null — so that consumers are forced to handle the missing case.

type Maybe<T> = { present: false } | { present: true; value: T }; const userId = graph.input<Maybe<string>>('userId', { present: false }); const greeting = graph.derived('greeting', g => { const u = g(userId); return u.present ? `Hello, ${u.value}` : 'Sign in to continue'; });

The Maybe wrapper costs you nothing at runtime, but it ensures downstream selectors cannot silently propagate a null that some other consumer was not prepared for. It also composes nicely with @causl/sync's ResourceState, where idle is the analogue of { present: false } and loaded carries the value.

5. Fanout (1-to-N)

A single input drives many derivations. This is the canonical Causl shape — a spreadsheet column with hundreds of formulas pointing at one cell — and the engine is built for it. The pattern is mostly about not getting in its way: register one derivation per consumer, let the engine schedule them via Kahn's topological recompute in Phase D, and let Phase G dispatch suppress subscribers whose value did not actually change.

const price = graph.input<number>('price', 100); const withTax = graph.derived('withTax', g => g.read(price) * 1.08); const withTip = graph.derived('withTip', g => g.read(price) * 1.18); const withDiscount = graph.derived('withDiscount', g => g.read(price) * 0.85); const formatted = graph.derived('formatted', g => `$${g.read(price).toFixed(2)}`); graph.subscribe(withTax, v => renderTax(v)); graph.subscribe(withTip, v => renderTip(v)); graph.subscribe(withDiscount, v => renderDiscount(v)); graph.subscribe(formatted, v => renderLabel(v));

One graph.commit('update', tx => tx.set(price, 200)) walks the topological order exactly once, recomputes each downstream, and dispatches one notification per subscriber whose output changed. There is no "fanout" API to call — the engine's pipeline is the fanout.

Watch for chatty subscribers. Fanout is cheap on the compute side; the cost is in subscriber callbacks. If you find yourself paying for 1,000 React re-renders per commit, your problem is not that Causl re-ran 1,000 derivations — that is the expected behaviour — but that 1,000 components are each subscribed individually. Move the subscription up the tree and let React reconcile.

6. Aggregation (N-to-1)

The mirror of fanout: many inputs feed a single derivation. Sums, averages, validity flags, dirty trackers. The engine's incremental recompute does not magically turn an O(N) sum into an O(1) delta — if your aggregate genuinely depends on every input, you are going to read every input. The pattern is about structuring the aggregate so it is easy to reason about and easy to swap for a more granular implementation if profiling demands it.

const lineItems = Array.from({ length: 12 }, (_, i) => graph.input<number>(`item:${i}`, 0), ); const subtotal = graph.derived('subtotal', g => lineItems.reduce((sum, n) => sum + g(n), 0), ); const allValid = graph.derived('allValid', g => lineItems.every(n => g(n) >= 0), );

For larger fan-ins (thousands of leaves), prefer a tree-shaped aggregation: per-bucket subtotals fanning into a grand total. Causl's Phase D scheduler is happy to process the wide-but-shallow shape; it becomes a problem only when subscribers force every intermediate to publish on every commit.

Don't aggregate inside a commit. If you find yourself writing tx.set(total, computeTotal()) inside a commit, you have inverted the data flow. The total should be a derived; the commit only writes the leaves. Writing aggregates as inputs makes them stale by construction, and the static @causl/checker lint will flag the shape.

7. Transient subscribers

A transient subscriber fires at most once and then auto-disposes itself at the end of the same commit pass that fired it. The pattern is ideal for one-shot reactions: "fire when the hydration completes," "fire the first time the form is valid," "fire when this resource transitions out of loading." Without it, every such reaction has to manage its own unsubscribe() handle from inside the callback, which is easy to forget and noisy at the call site.

graph.subscribe( hydrated, v => { if (v) startBackgroundSync(); }, { transient: true }, );

The synchronous initial fire that graph.subscribe always performs does not consume the one-shot — that fire surfaces the current value to the new observer. The transient counter advances only on the first Phase G fire after registration. If the registration's value never actually changes, the observer never fires and the registration is never disposed; transient means "fire at most once," not "exists for at most one commit." For the latter, register a normal subscription and call unsubscribe() from inside the callback.

8. Suspense integration

For async resources, @causl/sync's resource() models the lifecycle as a five-arm tagged union (idle | loading | loaded | stale | errored). React's <Suspense> and error boundaries want a value or a thrown Promise / Error. The bridge is useCauslSuspense:

import { resource } from '@causl/sync'; import { useCauslSuspense } from '@causl/react'; const userResource = resource(graph, 'user', async (id: string) => { const r = await fetch(`/api/users/${id}`); return r.json(); }); function UserPanel({ id }: { id: string }) { const user = useCauslSuspense(g => userResource.state(g, id)); return <h1>{user.name}</h1>; } // At the application level: <Suspense fallback={<Spinner />}> <ErrorBoundary fallback={<ErrorPanel />}> <UserPanel id="42" /> </ErrorBoundary> </Suspense>

The selector returns a ResourceState<T>; the hook projects it to T by suspending on loading (it throws the engine-anchored, identity-stable Promise carried on the state), throwing on errored, returning the cached value on stale, and suspending on idle until the next commit. Promise identity is what makes Suspense behave correctly across renders — never fabricate a Promise per render; let the engine or the adapter own it.

The stale arm returns, it does not throw. If the resource has a cached value but the data is known to be out of date, the hook returns the cached value rather than triggering Suspense again — the application already has something to render. Trigger a refetch explicitly if you want to recover.

9. Time-travelling debugger

Causl's commit log is a first-class graph value — a Behavior [Commit], in the language of the spec — which means time-travel is not a parallel system bolted onto the side. It is the same subscription API every other consumer uses, projected by @causl/devtools into a UI-friendly buffer.

import { commitLog, exportSnapshot, importSnapshot } from '@causl/devtools'; const logNode = commitLog(graph, { capacity: 200 }); graph.subscribe(logNode, (commits) => { renderTimeline(commits); // most-recent-first });

To rewind to an earlier state, capture a snapshot at the commit you want to inspect and re-apply it:

const snap = exportSnapshot(graph, { at: someEarlierCommit }); // Later, in a fresh graph or a sandboxed replay: const replay = createCausl(); importSnapshot(replay, snap);

Snapshots are deterministic re-applications of input state — they replay the commits that produced the original value, so derivations recompute from the same inputs and yield the same outputs on the same engine version. Reads are answered through graph.readAt(node, commitTime) when the retention cap still holds the commit; once evicted, the call returns a tagged Evicted arm rather than pretending the data is still there.

import { whyUpdated, whyNotUpdated, renderWhy } from '@causl/devtools'; // "Why did this derivation fire on the most recent commit?" const reason = whyUpdated(graph, summary); graph.subscribe(reason, (r) => console.log(renderWhy(r))); // "Why did this derivation NOT fire when I expected it to?" const noReason = whyNotUpdated(graph, summary); graph.subscribe(noReason, (r) => console.log(renderWhy(r)));

These lineage explainers return derived nodes, not plain function calls — that is the point. You can subscribe to the explanation itself, render it live in your devtools panel, and it will keep up with the graph automatically. Coupled with liveDerived, which lets you swap a compute closure on a registered derivation without restarting the host, this is "edit a derivation while it's running, watch the change propagate." If the engine cannot demo that, it has not earned the comparison to spreadsheets.

Retention is bounded. The commitHistoryCap option on createCausl() defaults to 1000 commits; older entries are evicted. Pass 0 or 1 for zero retention in memory-sensitive deployments. Time-travel that reaches past the cap returns an Evicted tag — your devtools UI should render an honest "this commit is no longer in memory" rather than a blank panel.

When patterns are not enough

Patterns are signposts, not laws. If you find yourself bending one of these shapes hard against the grain — registering a thousand families to simulate a relational join, fanning out to a hundred subscribers each of which writes back to the graph — pause and read the Where causl fits section on the home page. Some problems want a database; some want a message queue; some want a workflow engine. Causl is a transactional reactive engine for tangled dependency graphs. When the shape of your problem really is that, the patterns above will feel obvious. When it isn't, no amount of derivation will paper over the mismatch.

For the next layer of advice — error handling, performance tuning, testing strategies — continue to the other pages in this section.