# React to the viewer with events, not timers

Everything your interface needs to mirror is announced. Subscribe once, keep the unsubscribe or an `AbortSignal`, and drop the polling loop.

- Level: Basics
- Uses: `viewer.on()`, `{ once, signal }`, `AbortController`, `documentChanged`, `annotationsChanged`, `pageChanged`, `Kernstra.events.on()`, `licenseChanged`

There are two emitters, and subscribing to the wrong one is silent. Document lifecycle events are global and fire on `Kernstra.events`; everything else is per-viewer and fires on `viewer.on(...)`. A handler registered on the wrong emitter never runs and never warns, so each emitter is typed by its own map (`GlobalEventMap`, `ViewerEventMap`) and the mistake is a compile error.

## 1. Subscribe, and keep the unsubscribe

`on()` returns the unsubscribe. `options.once` hears the next emit only; `options.signal` unsubscribes when an `AbortSignal` aborts, so one `AbortController` can end every subscription a component made (a signal already aborted subscribes nothing). `viewer.off(event, handler)` with the same handler reference works too.

`state.ts`

```ts
const stop = viewer.on('pageChanged', ({ pageIndex }) => setCurrentPage(pageIndex));
stop();

const controller = new AbortController();
viewer.on('zoomChanged', ({ scale }) => setZoom(scale), { signal: controller.signal });
viewer.on('documentChanged', () => markDirty(), { signal: controller.signal });
viewer.on('exportCompleted', ({ sizeBytes }) => toast(sizeBytes), { once: true });
controller.abort(); // both gone
```

## 2. Mirror the document, not the DOM

`pageChanged` carries `{ pageIndex }` when the current page changed by scrolling, navigation, a link, or a reload that left fewer pages than the one shown; not by a zoom. `documentChanged` says **that** the document changed, with `source`, `tool`, `hasUnsavedChanges`, `canUndo` and `canRedo`. `annotationsChanged` says **what**: `{ pageIndex, changes }`, each change `{ type: 'added' | 'modified' | 'deleted', id }` where `id` is what `doc.annotations.get(id)` takes; it needs the `annotations` feature and is not fired for the annotations a document opens with.

`state.ts`

```ts
viewer.on('pageChanged', ({ pageIndex }) => pager.set(pageIndex + 1));

viewer.on('documentChanged', ({ source, hasUnsavedChanges, canUndo, canRedo }) => {
  saveButton.disabled = !hasUnsavedChanges;
  undoButton.disabled = !canUndo;
  redoButton.disabled = !canRedo;
  log(`changed by ${source}`);
});

viewer.on('annotationsChanged', async ({ pageIndex, changes }) => {
  for (const { type, id } of changes) {
    if (type === 'deleted') audit.remove(id);
    else audit.put(await doc.annotations.get(id), pageIndex);
  }
});
```

> A form field reports every commit of its value, each keystroke, on `documentChanged` as on `formFieldChanged`: debounce anything expensive.

## 3. The global emitter

`Kernstra.events.on(event, handler, options?)` takes the same options. `licenseChanged` carries a `LicenseState`: a valid license fills `env`, `plan`, `features`, `offline` and, when it has one, `expiresAt`; an invalid one fills `reason` (and `pauseReason` when paused). `documentLoaded` fires for every factory with `{ document, pageCount, hasPassword }`, so a host with several documents knows which; `documentClosed` carries the document, closed by then: compare it, never call it. `Kernstra.events` offers `on` and `off` only: the SDK alone emits.

`app.ts`

```ts
import { Kernstra } from '@kernstra/pdf-web-sdk';

// Kept across Kernstra.destroy(): subscribe once, at module level if you like.
Kernstra.events.on('licenseChanged', (state) => {
  if (state.valid) {
    exportButton.hidden = !state.features.includes('export');
  } else {
    banner.textContent = state.reason;
  }
});

Kernstra.events.on('documentLoaded', ({ pageCount }) => setPageCount(pageCount));
```

## Gotchas

- A handler that throws is logged and does not stop the others.
- `documentLoaded` is not fired when the viewer reloads the document after an edit or an undo.
- Callbacks decide; events observe. A callback (`callbacks.onDownload`, `onConfirm`, …) is where the SDK hands you a decision or a job of its own; everything that merely happened is an event, so any number of parts of your page can listen, and each can stop on its own.
- Nothing reports `hasUnsavedChanges` going back to `false`: the export's `exportCompleted`, or your own `markSaved()`, is that moment.
- Under a license without `extract`, the `FormFieldInfo` snapshot that `formFieldChanged` carries has `value` set to `''`; `documentChanged` carries no document content, so there is nothing to blank.

## Related

- [Events](https://kernstra.com/docs/web-sdk/api/events)
- [documentChanged](https://kernstra.com/docs/web-sdk/api/events#ev-documentchanged)
- [licenseChanged](https://kernstra.com/docs/web-sdk/api/events#ev-licensechanged)
- [EventEmitter](https://kernstra.com/docs/web-sdk/api/eventemitter)
- [Callbacks](https://kernstra.com/docs/web-sdk/config/callbacks)
- [Unsaved changes done right](https://kernstra.com/docs/web-sdk/recipes/unsaved-changes)

Web page: https://kernstra.com/docs/web-sdk/recipes/events-not-polling
