Skip to content

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),
);
// later
stop();

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.

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 from navigator.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.
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