Structuring Causl Graphs
Causl will let you register ten thousand inputs and a hundred
thousand derivations on a single Graph instance and
it will, mechanically, continue to work. The Phase D Kahn topo
recompute (SPEC §5.1)
visits only the affected frontier on each commit, so the engine
does not collapse under graph size. What collapses under graph
size is your mental model. This page is about preserving
it.
The shape of a causl graph is not architecture for its own sake. It is a load-bearing decision: it controls what the static checker sees, what the DevTools timeline shows, what subscribes to what, and which commits can land in parallel without conflict. The four sections below cover the patterns that hold up at scale, in roughly the order they tend to bite.
1. The god-graph anti-pattern
The most common shape new causl users reach for, almost without
thinking, is one module exporting one giant graph
with every input and derivation in the application registered on
it.
// store.ts — DO NOT DO THIS
import { createCausl } from '@causl/core';
export const graph = createCausl({ name: 'app' });
export const currentUser = graph.input('currentUser', null);
export const cartItems = graph.input('cartItems', []);
export const cartTotal = graph.derived('cartTotal', g => { /* … */ });
export const searchQuery = graph.input('searchQuery', '');
export const searchResults = graph.derived('searchResults',g => { /* … */ });
export const checkoutStep = graph.input('checkoutStep', 'address');
export const addressDraft = graph.input('addressDraft', { line1: '', city: '' });
export const paymentDraft = graph.input('paymentDraft', { card: '' });
export const orderHistory = graph.input('orderHistory', []);
// …200 more, indented to follow.
The first symptoms are mild. The file gets long. Imports get noisy. A new feature has to grep the file for "does someone already own a node like this?". These are aesthetic complaints, easy to dismiss.
The real costs land later. The name namespace is
flat, so collision risk grows linearly with the module's size.
graph.exportModel() — the bridge to
causl-check — emits a single IR document that
now must be scanned in its entirety for every lint run. The
DevTools timeline merges unrelated commits into one stream:
a commit that only touched the checkout draft is visually
indistinguishable from a commit that only touched the search
query. Most importantly, when a feature is deleted, the nodes
stay registered until you remember to call dispose on
each one, because they are reachable from the module's top-level
const exports.
The fix is not to split into N graphs. The fix is to split into N feature modules that each own a slice of one graph — or, when the feature is genuinely independent, into N graphs that each have a clear owner.
2. Per-feature decomposition
A feature in causl is a module that exposes a small bundle of nodes plus the operations that read or write them. The graph handle is an argument, not an import:
// features/cart.ts
import type { Graph } from '@causl/core';
export function createCart(graph: Graph) {
const items = graph.input<CartItem[]>('cart.items', []);
const couponCode = graph.input<string | null>('cart.couponCode', null);
const subtotal = graph.derived('cart.subtotal', g =>
g.read(items).reduce((sum, item) => sum + item.priceCents * item.qty, 0),
);
const discount = graph.derived('cart.discount', g => {
const code = g.read(couponCode);
if (!code) return 0;
return Math.floor(g.read(subtotal) * discountRate(code));
});
const totalCents = graph.derived('cart.total', g =>
g.read(subtotal) - g.read(discount),
);
function addItem(item: CartItem) {
graph.commit('cart/addItem', tx => {
tx.set(items, [...graph.read(items), item]);
});
}
function applyCoupon(code: string) {
graph.commit('cart/applyCoupon', tx => { tx.set(couponCode, code); });
}
return { items, subtotal, discount, totalCents, addItem, applyCoupon };
}
Two conventions in that example carry most of the structural weight:
-
Stable, dotted node IDs. Every ID is prefixed
with the feature name (
cart.items,cart.subtotal). The dotted segments are not parsed by the engine — the ID is opaque — but they give humans, the DevTools, and migration codemods a reliable per-feature search prefix. TheGRAPH_ID_REGEXexported from@causl/coredefines the allowed character set; within those rules, pick a hierarchy and stick to it. -
Commit intents are namespaced operations, not actions.
The first argument to
graph.commitis the intent string that the commit log records and that DevTools displays. Use'cart/addItem', not'set items'. When you grep the commit log for a bug, the slash convention means the feature owner is the first thing you see.
A feature module like the one above never imports a global graph. It receives one. That single change — passing the graph as a parameter to a factory — is what makes the rest of the patterns on this page possible.
Detailed explanation: why factories, not modules? A module's top-levelconstis evaluated once, at first import. If you register a node at the top level offeatures/cart.ts, you have to make surecart.tsis imported exactly once and that the graph it registers against exists by then. That seems easy until tests ask for a fresh graph per case, or SSR asks for a fresh graph per request, or HMR re-runs the module against the old graph and throwsDuplicateNodeError. A factory function takes the graph as a parameter and is safe to call repeatedly against different graphs.
3. Mounting and unmounting subgraphs
Per-feature decomposition handles the static shape of the application: features that exist for the lifetime of the process. Many real applications also have dynamic subgraphs: one node per row in a virtualised list, one bundle of nodes per open document tab, one bundle per WebSocket subscription. These come and go.
The wrong instinct is to lazily register nodes inside a render
function or effect. That works once. The second mount throws
DuplicateNodeError, the second unmount leaks the
registration, and the DevTools timeline fills with
DerivedRegistrationStackOverflowError entries because
a node was created during a commit it had no business participating
in.
The right pattern in React is
useCauslFamily(key, factory) from
@causl/react. It gives you a stable
Node<T> per key, identity-shared across
consumers, and disposes the node when the last consumer unmounts.
import { useCauslFamily, useCausl } from '@causl/react';
import type { Graph, Node } from '@causl/core';
type Row = { id: string; label: string; checked: boolean };
function rowNode(graph: Graph, key: string): Node<Row> {
return graph.input(key, { id: key.slice('row:'.length), label: '', checked: false });
}
function RowView({ rowId }: { rowId: string }) {
const node = useCauslFamily(`row:${rowId}`, rowNode);
const row = useCausl(g => g.read(node));
return <li>{row.checked ? '[x]' : '[ ]'} {row.label || row.id}</li>;
}
Three properties of useCauslFamily are worth pinning
down. First, the factory runs exactly once per key: the first
mount with a new key invokes it; later mounts return the cached
node. Second, the key is opaque to causl — the engine sees
whatever ID the factory passes to graph.input /
graph.derived. Third, dispose is deferred, not
synchronous; if the same key remounts in the next microtask, the
node is reused. That last property is what makes scroll-driven
virtualisation cheap: a row that scrolls out and back in does not
pay re-registration cost.
Outside React, the same pattern is a hand-rolled registry that
reference-counts each key and calls the dispose hook on the
adapter layer when the count reaches zero. The mechanics live in
SPEC §9 (lifecycle) and the worked source is
packages/react/src/useCauslFamily.ts;
copy the bones, swap React's useEffect for your own
lifecycle hook.
Composing a subgraph, not a single node
Many dynamic features need more than one node per key — a row has an input for the raw record plus a derivation for its computed display state. The convention is to return both from the factory inside a small record:
function rowBundle(graph: Graph, key: string) {
const raw = graph.input(`${key}.raw`, defaultRow(key));
const display = graph.derived(`${key}.display`, g => formatRow(g.read(raw)));
return { raw, display };
}
// The family hook holds the bundle as the value; identity stays stable.
const { raw, display } = useCauslFamily(`row:${rowId}`, rowBundle);
The disposal contract still holds: when the last consumer of the
bundle unmounts, both nodes are released through the adapter-layer
dispose hook and the namespace is freed.
4. One graph or many?
Causl supports both shapes. createCausl() returns an
independent Graph handle — you may have ten of
them in the same process, each with its own commit log, its own
DevTools timeline, and its own snapshot file. The question is
which costs you are willing to pay.
The single-graph default is the right one for most applications. A commit spans only one graph; if two features are correlated — "when the cart changes, the checkout step might invalidate" — they must share a graph, because there is no cross-graph transaction primitive. Splitting them means writing your own coordination code, which is exactly the thing causl exists to take off your hands.
Multiple graphs are the right answer in a small number of situations:
| Situation | Why a separate graph helps |
|---|---|
| SSR per request |
Each request needs an isolated graph that lives only for the
response. Reuse the same factory; just call
createCausl() per request.
|
| Embedded plugin / iframe | The plugin owns its own state and never participates in the host's transactions. A separate graph keeps the namespaces disjoint and makes the trust boundary explicit. |
| Background worker pool | A worker that computes a derivation off the critical commit path may keep its own graph and forward results via the host's bridge. The two graphs never share a commit. |
| Independent tabs of the same product | A multi-document interface where each tab is genuinely independent — closing the tab disposes the graph. Cheaper than tagging every node with a tab ID. |
The honest cost of multiple graphs: subscriptions cross a process boundary you now own, commits can't atomically affect more than one graph, the checker runs once per graph (not once for the union), and DevTools shows one panel per graph. Reach for it when the isolation is the point. Avoid it when you only wanted namespacing — that is what dotted IDs and feature factories are for.
When causl is not the right tool. If the work you are doing is "pipe one stream of events into one rendering target" — a feed, a chat tail, a log viewer — the transactional commit model is more machinery than the problem needs. Causl's win is when many cells depend on many cells, and a single user gesture has to leave the graph in a consistent state. If you do not have that shape, a simpler reactive primitive will serve you better.
5. A short checklist
Before you ship a feature, walk this list. Each item maps to a bug class that an early reader of this page has actually hit.
-
Every node ID is prefixed with its feature name. No bare
'count'or'value'at the top of the namespace. -
No top-level
graph.inputorgraph.derivedat module load. Registration happens inside a factory that takes the graph as a parameter. -
Every commit has a slash-namespaced intent
(
'cart/addItem', not'addItem'). The commit log is read by humans during incidents. -
Per-row or per-document state goes through
useCauslFamily(in React) or an equivalent ref-counted registry. Never lazily register in a render or an ad-hoc effect. - If a feature reaches across to another feature's nodes more than twice, write the link explicitly: a derivation that takes both as dependencies, owned by neither feature.
- New graphs are introduced for isolation, not for organisation. If the answer to "why a new graph?" is "to keep names tidy", use a feature factory and dotted IDs instead.
A graph that follows these rules tends to stay legible at ten thousand nodes. A graph that ignores them tends to need a rewrite somewhere around five hundred.
Related reading
-
Writing Derivations —
the mechanics of the tracked
getand how to keep derivations deterministic. - Commits and Subscriptions — the Phase A–H pipeline in user-visible terms.
-
Using Causl with React — the
rest of the
@causl/reacthook surface. - SPEC.md §5 (the commit pipeline) and §9 (lifecycle) for the normative rules behind the patterns above.