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:
- Each note has an
id, atitle, abody, and anupdatedAttimestamp. - Notes are stored in a single input — a record keyed by note id.
- A derived sorted index renders the sidebar in
updatedAtorder without re-sorting on every render. - A derived word count tracks the active note's body live.
- The local input is persisted to
localStorageviapersistedInputso the page survives a refresh. - A
resourcefrom@causl/syncmodels a pull from the server through the Idle → Loading → Loaded / Errored / Stale statechart. - A
ConflictRegistryfrom@causl/syncsurfaces server-vs-local divergence as first-class state. - The UI is React, driven by a typed
Msgunion and an MVUupdaterunner.
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: whypersistedInputis restricted to inputs. A derived node at time t is by definition a pure function of its inputs at the sameGraphTime. Persisting one would mean writing a cache that goes stale the instant any upstream formula or input changes.persistedInputrefuses 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 auseStatenext 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 onecreateConflictRegistrycall. 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:
-
Reference identity is not contractual.
graph.read(node)may return an equal-by-content but not===value across commits. If a downstream consumer keys a memo on referential identity, wrap the value inuseMemowith a content key. SPEC §15.1 names this as the H1 hazard. -
Very large inputs are awkward. Storing 100k
notes in one
NoteMapinput means every write copies the whole record. Split into per-note inputs via the family pattern (seeuseCauslFamily) once the working set crosses a few thousand entries. - Causl is not a CRDT. The conflict registry surfaces divergence and lets you resolve it; it does not merge text three-way for you. Bring an OT or CRDT library if your sync model needs mid-typing convergence.
-
WASM backend is preview.
loadWasmBackend()currently returns a TypeScript wrapper around the JS engine; the full Rust port is deferred to post-0.9.0 behind explicit GO/NO-GO criteria. Write to the public API and you will get the speed-up when it lands.
8. Recap of the five pieces
| Layer | Lives in | What it does here |
|---|---|---|
| Inputs | graph.ts | notes, selectedId, draftDirty — the only mutable state |
| Derived | projections.ts | sortedNotes, activeNote, wordCount, noteCount — pure functions of inputs |
| Lifecycle statechart | sync.ts | resource for the pull, ConflictRegistry for divergence |
| Persistence | graph.ts | persistedInput binds two inputs to localStorage |
| React | App.tsx, messages.ts, update.ts | Provider, 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
- Usage Guide — patterns for selectors, families, and SSR hydration.
- Best Practices — naming conventions, lint rules, and the static checker.
- API Reference — the canonical signatures for every symbol used in this tutorial.
- FAQ — answers to "do I need a commit for that?" and other common questions.