Part 4 — reactivity and UI
watch — re-run a query when the answer changes
Section titled “watch — re-run a query when the answer changes”const stop = engine.watch( 'g:acme', (e) => e.listKind('g:acme', 'task'), (tasks) => render(tasks),);// laterstop();The callback fires once immediately, and then only when the answer changes — not on every write anywhere in the graph. Without that comparison, every keystroke re-renders every view, which is how local-first apps get a reputation for being slow at the one thing they should be fast at.
A watcher that throws is reported (error event, scope watch) and stays
alive.
function useSkmQuery<T>(graphId: string, query: (e: SkmEngine) => Promise<T>, initial: T): T { const [value, setValue] = useState(initial); useEffect(() => engine.watch(graphId, query, setValue), [graphId]); return value;}
function Tasks() { const tasks = useSkmQuery('g:acme', (e) => e.listKind('g:acme', 'task'), []); return ( <ul> {tasks.map((t) => ( <li key={t.id}>{t.data.title}</li> ))} </ul> );}Keep query stable (module scope, or useCallback) so the effect does not
re-subscribe on every render.
Status, for an honest UI
Section titled “Status, for an honest UI”engine.status;// {// storeKind: 'opfs-sqlite',// deviceId: 'd_...',// deviceOk: true, // false when this id is bound to different keys upstream// pending: 0, // records queued but not pushed// syncing: false,// lastSyncAt: 1767...,// lastError: null, // { at, message } — draw "offline" from this// rejected: 0, // records the relay refused for good// graphs: { 'g:acme': { key: 'ok', epoch: 3, rotationRequired: false } },// }
engine.on('status', () => rerenderTheChrome());Four of these deserve a place in the UI:
lastError— the honest source for “offline”. Do not guess fromnavigator.onLine.pending— “3 changes not yet synced” is worth showing.rejected— a record that exists on this device and nowhere else. That is a bug worth surfacing, not a counter worth ignoring.graphs[g].rotationRequired— an alarm, not a suggestion.
Events
Section titled “Events”const off = engine.on('record.rejected', (e) => log.warn(e));engine.on('*', (e) => debugPanel.push(e));| event | payload | when |
|---|---|---|
change |
{graphId} |
records in a graph changed (local write or pull) |
status |
— | anything in engine.status changed |
error |
{scope, graphId?, message, retryable} |
a transport or key failure worth surfacing |
record.rejected |
{graphId, recordId, failure} |
a record was refused, on the way in or out |
sync.done |
{graphId, pushed, pulled} |
a graph finished a sync run |
keys.granted |
{graphId, devices} |
this device granted keys to other devices |
record.rejected failure reasons: shape, unknown-kind, schema,
too-large, signature (with a detail: unsigned, unknown-device,
bad-signature, author-mismatch, revoked-device, id-mismatch,
device-has-no-signing-key), and relay-rejected.
Error scopes worth handling by name:
| scope | means |
|---|---|
keys.behind |
this device is holding writes — it lacks the graph’s current key |
keys.unopenable |
a wrap arrived that this device cannot open; it was discarded |
device.revoke |
a revocation completed but some graphs were not rotated from here |
push / pull |
transport failure; retryable says whether it is worth waiting |