# Part 4 — reactivity and UI

## `watch` — re-run a query when the answer changes

```ts
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.

## React

```tsx
function useArgalaQuery<T>(graphId: string, query: (e: ArgalaEngine) => Promise<T>, initial: T): T {
  const [value, setValue] = useState(initial);
  useEffect(() => engine.watch(graphId, query, setValue), [graphId]);
  return value;
}

function Tasks() {
  const tasks = useArgalaQuery('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

```ts
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
//   storage: { persisted: true | false | null, usage, quota, checkedAt },
//   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.
- **`storage.persisted === false` with `pending > 0`** — the browser has not
  promised to keep this store, and some writes have not yet been pushed.
  That is the combination that loses somebody's afternoon.
- **`graphs[g].rotationRequired`** — an alarm, not a suggestion.

## Events

```ts
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   |
| `storage.evictable` | unsynced writes on a store the browser may throw away             |
| `storage.pressure`  | this origin is near its storage quota                             |

---
