Part 8: A Real-World Application

Parts 1 through 7 introduced the pieces one at a time: a graph, inputs, derived nodes, commits, subscribers, a lifecycle statechart for async resources, and a React binding. This part wires the whole assembly into a single small-but-realistic application — an offline-capable notes editor that persists locally, syncs to a server, and surfaces conflicts in the UI. By the end you will have a complete mental picture of how the five layers compose and where the seams sit.

The application is intentionally small. The goal is not to ship a notes product; it is to put every concept from the prior parts on screen at once and see which file each one lives in. Real applications add validation, drag-and-drop, search, history, and a dozen other concerns, but they extend this skeleton rather than replace it.

What we are building

A notes app with the following behaviour:

1. The graph and its inputs

Begin with the graph itself and the small set of inputs that the application owns. The "information model" — the user's mental world of notes — and the "editor controller" — the selection, the dirty flag, the open modal — share the same graph but live in their own id namespaces. The split mirrors the API reference and the SPEC §13 boundary discipline.

// graph.ts import { createCausl, type InputNode, type Graph } from '@causl/core' import { persistedInput, localStorageAdapter } from '@causl/persistence' export interface Note { readonly id: string readonly title: string readonly body: string readonly updatedAt: number } export type NoteMap = Readonly<Record<string, Note>> export interface AppNodes { readonly notes: InputNode<NoteMap> readonly selectedId: InputNode<string | null> readonly draftDirty: InputNode<boolean> } export function buildGraph(): { graph: Graph; nodes: AppNodes } { const graph = createCausl({ name: 'notes-app' }) const storage = localStorageAdapter('notes-app') // Information-model inputs — persisted across reloads. const notes = persistedInput<NoteMap>(graph, 'notes', {}, { key: 'notes-app:notes', storage, version: 1, }) // Editor-controller inputs — also persisted so the user lands on // the same note after a refresh, but in a separate key. const selectedId = persistedInput<string | null>(graph, 'selectedId', null, { key: 'notes-app:selectedId', storage, version: 1, }) // Pure ephemeral state — not persisted. A draft-dirty bit reset on // every cold start is fine and desirable. const draftDirty = graph.input<boolean>('draftDirty', false) return { graph, nodes: { notes, selectedId, draftDirty } } }
Detailed explanation: why persistedInput is restricted to inputs. A derived node at time t is by definition a pure function of its inputs at the same GraphTime. Persisting one would mean writing a cache that goes stale the instant any upstream formula or input changes. persistedInput refuses derived handles at the type level so that the on-disk envelope is always recomputable and never authoritative-but-wrong. See @causl/persistence.

2. Derived projections for the UI

The sidebar wants notes in recency order. The footer wants the active note's word count. The active-note pane wants the selected note's content. Each of these is a pure function of the inputs, so each is a derived node — registered once, recomputed automatically, and not stored on disk.

// projections.ts import type { Graph, DerivedNode } from '@causl/core' import type { AppNodes, Note } from './graph.js' export interface AppProjections { readonly sortedNotes: DerivedNode<readonly Note[]> readonly activeNote: DerivedNode<Note | null> readonly wordCount: DerivedNode<number> readonly noteCount: DerivedNode<number> } export function buildProjections(graph: Graph, nodes: AppNodes): AppProjections { const sortedNotes = graph.derived<readonly Note[]>('sortedNotes', g => { const map = g.read(nodes.notes) return Object.values(map).slice().sort((a, b) => b.updatedAt - a.updatedAt) }) const activeNote = graph.derived<Note | null>('activeNote', g => { const id = g.read(nodes.selectedId) if (id === null) return null const note = g.read(nodes.notes)[id] return note ?? null }) const wordCount = graph.derived<number>('wordCount', g => { const note = g.read(activeNote) if (!note) return 0 return note.body.trim().split(/\s+/).filter(Boolean).length }) const noteCount = graph.derived<number>('noteCount', g => { return Object.keys(g.read(nodes.notes)).length }) return { sortedNotes, activeNote, wordCount, noteCount } }

Notice that wordCount reads activeNote, not the raw inputs. The engine's Phase-D Kahn schedule recomputes each derived node at most once per commit and only when one of its dependencies has changed, so chaining derived nodes does not pay an extra cost — it is the same recompute either way and the chained form is easier to read.

3. Messages and the MVU runner

Every user action becomes a typed Msg. The runner is the single seam where a message turns into a commit, which means the entire write surface of the app is the union below. Adding a feature means adding a variant; the type system then forces a matching handler.

// messages.ts import { defineMsgs, payload, type MsgOf } from '@causl/react' export const msg = defineMsgs({ 'note/create': null, 'note/select': payload<{ id: string }>(), 'note/edit-title': payload<{ title: string }>(), 'note/edit-body': payload<{ body: string }>(), 'note/delete': payload<{ id: string }>(), 'sync/pull': null, 'sync/resolve-conflict': payload<{ id: string; pick: 'local' | 'remote' }>(), }) export type AppMsg = MsgOf<typeof msg> // update.ts import { createUpdate } from '@causl/react' import type { Graph } from '@causl/core' import type { AppMsg } from './messages.js' import type { AppNodes, Note, NoteMap } from './graph.js' export function buildUpdate(nodes: AppNodes) { return createUpdate<AppMsg>({ 'note/create': (_m, g) => { g.commit('note/create', tx => { const id = crypto.randomUUID() const fresh: Note = { id, title: 'Untitled', body: '', updatedAt: Date.now() } const prev = g.read(nodes.notes) const next: NoteMap = { ...prev, [id]: fresh } tx.set(nodes.notes, next) tx.set(nodes.selectedId, id) }) }, 'note/select': (m, g) => { g.commit('note/select', tx => tx.set(nodes.selectedId, m.id)) }, 'note/edit-title': (m, g) => { g.commit('note/edit-title', tx => { const id = g.read(nodes.selectedId) if (id === null) return const prev = g.read(nodes.notes) const target = prev[id] if (!target) return tx.set(nodes.notes, { ...prev, [id]: { ...target, title: m.title, updatedAt: Date.now() }, }) tx.set(nodes.draftDirty, true) }) }, 'note/edit-body': (m, g) => { g.commit('note/edit-body', tx => { const id = g.read(nodes.selectedId) if (id === null) return const prev = g.read(nodes.notes) const target = prev[id] if (!target) return tx.set(nodes.notes, { ...prev, [id]: { ...target, body: m.body, updatedAt: Date.now() }, }) tx.set(nodes.draftDirty, true) }) }, 'note/delete': (m, g) => { g.commit('note/delete', tx => { const prev = g.read(nodes.notes) const { [m.id]: _gone, ...rest } = prev tx.set(nodes.notes, rest) if (g.read(nodes.selectedId) === m.id) { tx.set(nodes.selectedId, null) } }) }, 'sync/pull': (_m, g) => { /* wired in the next section */ }, 'sync/resolve-conflict': (_m, g) => { /* wired in the next section */ }, }) }

Two things to note. First, every handler ends in exactly one graph.commit(...). That is the contract: one message, one commit, one new GraphTime, one Phase-G subscriber dispatch. Second, the handlers read the current inputs inside the commit closure, which is how immutable record updates remain correct under concurrent dispatch — each commit sees the previous frozen snapshot, computes the next, and atomically swaps.

4. The sync resource and the conflict registry

The notes are local first, but the server is the eventual source of truth. We model the pull through @causl/sync's resource, which is the ResourceFleet sub-statechart: Idle → Loading → (Loaded | Errored) with an optional Stale transition. The state is observable from the graph like any other node, so the UI can show a spinner or an error banner just by reading it.

// sync.ts import type { Graph } from '@causl/core' import { resource, createConflictRegistry } from '@causl/sync' import type { AppNodes, Note, NoteMap } from './graph.js' export interface ServerNote extends Note { readonly serverVersion: number } export function buildSync(graph: Graph, nodes: AppNodes) { const pullResource = resource<readonly ServerNote[]>(graph, { id: 'sync/pull', initial: [], fetch: async () => { const res = await fetch('/api/notes') if (!res.ok) throw new Error(`HTTP ${res.status}`) return (await res.json()) as readonly ServerNote[] }, }) // The conflict registry overlays divergence onto the notes input. // Each open conflict is a derived view that records the local value, // the remote value, and the resolution state. const conflicts = createConflictRegistry<string, { local: Note; remote: ServerNote }>(graph, { id: 'conflicts/notes', detect: g => { const local = g.read(nodes.notes) const remote = g.read(pullResource.value) const out: Record<string, { local: Note; remote: ServerNote }> = {} for (const r of remote) { const l = local[r.id] if (l && l.updatedAt !== r.updatedAt) out[r.id] = { local: l, remote: r } } return out }, }) return { pullResource, conflicts } }

Now we can wire the two sync messages in update.ts:

// update.ts (excerpt — replacing the two stubs) 'sync/pull': (_m, g) => { // resource.refresh() is itself a commit; no extra g.commit() needed. void sync.pullResource.refresh() }, 'sync/resolve-conflict': (m, g) => { g.commit('sync/resolve-conflict', tx => { const conflict = g.read(sync.conflicts.byKey)[m.id] if (!conflict) return const next: Note = m.pick === 'local' ? conflict.local : conflict.remote const prev = g.read(nodes.notes) tx.set(nodes.notes, { ...prev, [m.id]: next }) }) sync.conflicts.resolve(m.id) },
Detailed explanation: why conflicts live in the graph. A common shortcut is to stash conflicts in a useState next to the React component that shows them. That works until a second component needs to know, or the conflict survives a navigation, or the conflict count must roll up into a sidebar badge. Placing conflicts in the graph means every part of the UI sees the same set, sorted by the same rules, and the resolution is one message dispatch away from anywhere. The cost is one createConflictRegistry call. See SPEC §10 for the ConflictRegistry orthogonal region in the composite lifecycle.

5. Mounting it all in React

The provider takes the graph and the update runner. Each component reads only what it needs through useCauslNode or useCausl(selector), and dispatches through the typed hook. There is no global store object outside the graph; there is no reducer outside the runner.

// App.tsx import { CauslProvider, useCausl, useCauslNode, useDispatch } from '@causl/react' import { buildGraph } from './graph.js' import { buildProjections } from './projections.js' import { buildSync } from './sync.js' import { buildUpdate } from './update.js' import { msg, type AppMsg } from './messages.js' const { graph, nodes } = buildGraph() const projections = buildProjections(graph, nodes) const sync = buildSync(graph, nodes) const update = buildUpdate(nodes /*, sync, projections — wire as needed */) export function App() { return ( <CauslProvider graph={graph} update={update}> <Layout /> </CauslProvider> ) } function Layout() { return ( <div className="layout"> <Sidebar /> <Editor /> <StatusBar /> </div> ) } function Sidebar() { const list = useCauslNode(projections.sortedNotes) const selectedId = useCauslNode(nodes.selectedId) const dispatch = useDispatch<AppMsg>() return ( <aside> <button onClick={() => dispatch(msg['note/create']())}>New note</button> <ul> {list.map((n) => ( <li key={n.id} aria-current={n.id === selectedId} onClick={() => dispatch(msg['note/select']({ id: n.id }))} > {n.title || 'Untitled'} </li> ))} </ul> </aside> ) } function Editor() { const note = useCauslNode(projections.activeNote) const dispatch = useDispatch<AppMsg>() if (!note) return <main>Select or create a note.</main> return ( <main> <input value={note.title} onChange={(e) => dispatch(msg['note/edit-title']({ title: e.target.value }))} /> <textarea value={note.body} onChange={(e) => dispatch(msg['note/edit-body']({ body: e.target.value }))} /> <button onClick={() => dispatch(msg['note/delete']({ id: note.id }))}>Delete</button> </main> ) } function StatusBar() { // useCausl selector is the right choice here: one re-render per // status change, not three. const { wordCount, noteCount, syncState, conflictCount } = useCausl(g => ({ wordCount: g.read(projections.wordCount), noteCount: g.read(projections.noteCount), syncState: g.read(sync.pullResource.state).kind, conflictCount: Object.keys(g.read(sync.conflicts.byKey)).length, })) const dispatch = useDispatch<AppMsg>() return ( <footer> <span>{noteCount} notes · {wordCount} words</span> <span>Sync: {syncState}</span> {conflictCount > 0 && <span>{conflictCount} conflicts</span>} <button onClick={() => dispatch(msg['sync/pull']())}>Pull</button> </footer> ) }

Three different React hooks appear here, each for a reason worth naming. useCauslNode binds one component to one node: Sidebar's list only re-renders when sortedNotes changes, Editor only when activeNote changes. useCausl(selector) is for multi-node projections; the selector's return is diffed with Object.is so a status-bar render is one event per real change, not four. useDispatch returns a stable function so child components do not churn dependency arrays.

6. What persistence buys you

Refresh the tab. The notes input rehydrates from localStorage before the first React render, the selectedId input rehydrates too, and the derived projections recompute deterministically from those inputs. Nothing in derived is read from disk — sortedNotes is recomputed from notes on first read, exactly as it would be on a cold render, and the result is identical because the inputs are. The draftDirty flag, deliberately not persisted, comes up false: cold start always means "nothing pending."

Schema evolution is the one place application code has to think about persistence. The day a tags field is added to a Note, bump version from 1 to 2 and supply a migrate callback that maps old envelopes forward. Without a migrate, the persistence layer emits a typed PersistenceError of kind migrate-missing and falls back to initial — loud, audible, and never silently destructive.

7. Where causl is — and isn't — the right tool

The application above plays to causl's strengths: a moderate number of inputs, several derived views with overlapping dependencies, transactional intent ("a click is one event"), and a need for conflicts and sync state to be first-class observable values. Causl's commit-time recompute and Phase-G subscribe pipeline keep every consumer in lockstep, and the type system pushes "impossible states" to the edges.

A few honest limits worth naming before you grow the app:

8. Recap of the five pieces

LayerLives inWhat it does here
Inputsgraph.tsnotes, selectedId, draftDirty — the only mutable state
Derivedprojections.tssortedNotes, activeNote, wordCount, noteCount — pure functions of inputs
Lifecycle statechartsync.tsresource for the pull, ConflictRegistry for divergence
Persistencegraph.tspersistedInput binds two inputs to localStorage
ReactApp.tsx, messages.ts, update.tsProvider, typed dispatch, three hook shapes

Every concept from Parts 1 through 7 has a corresponding file in this layout. From here, growing the app means growing one of the columns above — adding a derived projection, adding a message variant, adding a persisted input — rather than threading cross-cutting state through the React tree.

Next steps