> Lettrove docs Next · https://docs.lettrove.com/docs/get-started/events

# Listening to events

The editor tells your page what happens inside it: a design opened, changed, saved; a block
selected; an image uploaded; the theme switched; an option adjusted; an error. You listen, and
your product reacts: keep the design's id, show "Saved", preview live, log what went wrong.

Nothing waits for your listener. It cannot slow the editor down, and one that throws does not
affect it. Every payload may gain fields within 1.x, so ignore fields you do not know. The
[Events reference](../editor/events.md) lists every field of every event; this page shows what to
do with them.

## The nine events

| Event | When | Use it for |
|---|---|---|
| `ready` | The editor is on screen and answers calls | Enabling your own buttons; reading which release opened |
| `design:loaded` | A design opened, new or existing | Knowing the id from the start; noticing an upgrade from an older format |
| `design:updated` | The design changed (a burst of edits is one event) | "Saving…", a live preview, your own copy of the design |
| `design:saved` | The design was saved (Lettrove saves as your user works) | **Keeping the design's id** with your own record; "Saved" |
| `selection:changed` | The selected block changed, or nothing is selected | Help or controls for the kind of block selected |
| `image:uploaded` | An upload is ready to use | Logging, your own count of images |
| `theme:changed` | Your user used the editor's light / dark switch | Remembering their choice for next time |
| `config:adjusted` | An option you passed was dropped or changed | Finding a mistake while building |
| `error` | Something went wrong that you may want to show or log | Showing a message by `code`; logging |

In the order they happen in a session:

```text
design:loaded      { designId: '01J9ZQ…', designVersion: 3 }
ready              { release: '1.0.0', protocol: 1, designVersion: 3 }
selection:changed  { blockId: 'hdg_k2x4q7m1', type: 'heading' }
design:updated     { design: {…}, revision: 1, change: { kind: 'edit', … } }
design:saved       { designId: '01J9ZQ…', revision: 2 }
```

## Step 1: listen from the start

Pick your framework. Each example listens for the two events almost every product needs, the
design being saved and an error, and shows where the rest go.

### Next.js

Every event is a prop of `<LettroveEditor />`: `onReady`, `onDesignLoaded`, `onDesignUpdated`,
`onDesignSaved`, `onSelectionChanged`, `onImageUploaded`, `onThemeChanged`, `onConfigAdjusted`
and `onError`. The editor runs in the browser, so this is a client component, as on the
[Next.js page](./nextjs.md):

```tsx title="app/email-editor.tsx"
'use client';
import { LettroveEditor } from '@lettrove/react';

async function getToken() {
  const response = await fetch('/lettrove-token', { method: 'POST' });
  return (await response.json()).token;
}

export function EmailEditor({ recordId }: { recordId: string }) {
  return (
    <LettroveEditor
      publishableKey={process.env.NEXT_PUBLIC_LETTROVE_PUBLISHABLE_KEY!}
      getToken={getToken}
      onDesignSaved={({ designId }) => {
        // Keep the design's id with your own record (an API route, say)
        void fetch(`/api/records/${recordId}`, {
          method: 'PATCH',
          body: JSON.stringify({ lettroveDesignId: designId }),
        });
      }}
      onError={(error) => console.error('Lettrove', error.code, error.message)}
      style={{ height: '100vh' }}
    />
  );
}
```

Inline functions are fine: a new function on every render never reopens the editor.

### React

Every event is a prop of `<LettroveEditor />`: `onReady`, `onDesignLoaded`, `onDesignUpdated`,
`onDesignSaved`, `onSelectionChanged`, `onImageUploaded`, `onThemeChanged`, `onConfigAdjusted`
and `onError`.

```tsx title="src/Editor.tsx"
import { LettroveEditor } from '@lettrove/react';

async function getToken() {
  const response = await fetch('/lettrove-token', { method: 'POST' });
  return (await response.json()).token;
}

export function Editor({ recordId }: { recordId: string }) {
  return (
    <LettroveEditor
      publishableKey="lt_pk_test_paste_yours_here"
      getToken={getToken}
      onDesignSaved={({ designId }) => {
        // Keep the design's id with your own record
        void fetch(`/api/records/${recordId}`, {
          method: 'PATCH',
          body: JSON.stringify({ lettroveDesignId: designId }),
        });
      }}
      onError={(error) => console.error('Lettrove', error.code, error.message)}
      style={{ height: '100vh' }}
    />
  );
}
```

Inline functions are fine: a new function on every render never reopens the editor.

### Vue

Every event is a component event, in kebab-case: `@ready`, `@design-loaded`, `@design-updated`,
`@design-saved`, `@selection-changed`, `@image-uploaded`, `@theme-changed`, `@config-adjusted`
and `@error`.

```vue title="src/App.vue"
<script setup lang="ts">
import {
  LettroveEditor, type EditorEvents, type LettroveEmbedError,
} from '@lettrove/vue';

const props = defineProps<{ recordId: string }>();

async function getToken() {
  const response = await fetch('/lettrove-token', { method: 'POST' });
  return (await response.json()).token;
}

function onSaved({ designId }: EditorEvents['design:saved']) {
  // Keep the design's id with your own record
  void fetch(`/api/records/${props.recordId}`, {
    method: 'PATCH',
    body: JSON.stringify({ lettroveDesignId: designId }),
  });
}

function onError(error: LettroveEmbedError) {
  console.error('Lettrove', error.code, error.message);
}
</script>

<template>
  <LettroveEditor
    publishable-key="lt_pk_test_paste_yours_here"
    :get-token="getToken"
    @design-saved="onSaved"
    @error="onError"
    style="height: 100vh"
  />
</template>
```

### Angular

Listeners go in `on` when you open the editor, so nothing is missed while it loads. Add more at
any time with `editor.on()`, which returns a function that stops listening.

```ts title="src/app/app.ts"
import {
  AfterViewInit, Component, ElementRef, OnDestroy, viewChild,
} from '@angular/core';
import { createEditor, type EditorHandle } from '@lettrove/embed';

async function getToken() {
  const response = await fetch('/lettrove-token', { method: 'POST' });
  return (await response.json()).token;
}

@Component({
  selector: 'app-root',
  template: '<div #editor style="height: 100vh"></div>',
})
export class App implements AfterViewInit, OnDestroy {
  private readonly container =
    viewChild.required<ElementRef<HTMLDivElement>>('editor');
  private editor: EditorHandle | null = null;
  recordId = 'rec_123'; // your own record

  async ngAfterViewInit() {
    this.editor = await createEditor({
      container: this.container().nativeElement,
      publishableKey: 'lt_pk_test_paste_yours_here',
      getToken,
      on: {
        'design:saved': ({ designId }) => {
          // Keep the design's id with your own record
          void fetch(`/api/records/${this.recordId}`, {
            method: 'PATCH',
            body: JSON.stringify({ lettroveDesignId: designId }),
          });
        },
        error: ({ code, message }) => console.error('Lettrove', code, message),
      },
    });
  }

  ngOnDestroy() {
    this.editor?.destroy();
  }
}
```

### SvelteKit

Listeners go in `on` when you open the editor, so nothing is missed while it loads. Add more at
any time with `editor.on()`, which returns a function that stops listening.

```svelte title="src/routes/+page.svelte"
<script lang="ts">
  import { onMount } from 'svelte';
  import { createEditor, type EditorHandle } from '@lettrove/embed';

  let { recordId }: { recordId: string } = $props();
  let container: HTMLDivElement;
  let editor: EditorHandle | null = null;

  async function getToken() {
    const response = await fetch('/lettrove-token', { method: 'POST' });
    return (await response.json()).token;
  }

  onMount(() => {
    createEditor({
      container,
      publishableKey: 'lt_pk_test_paste_yours_here',
      getToken,
      on: {
        'design:saved': ({ designId }) => {
          // Keep the design's id with your own record
          void fetch(`/api/records/${recordId}`, {
            method: 'PATCH',
            body: JSON.stringify({ lettroveDesignId: designId }),
          });
        },
        error: ({ code, message }) => console.error('Lettrove', code, message),
      },
    }).then((opened) => (editor = opened));
    return () => editor?.destroy();
  });
</script>

<div bind:this={container} style="height: 100vh"></div>
```

### HTML

Listeners go in `on` when you open the editor, so nothing is missed while it loads. Add more at
any time with `editor.on()`, which returns a function that stops listening.

```html title="index.html"
<div id="editor" style="height: 100vh"></div>

<script src="https://cdn.jsdelivr.net/npm/@lettrove/embed@1/dist/lettrove.iife.js"></script>
<script>
  async function getToken() {
    const response = await fetch('/lettrove-token', { method: 'POST' });
    return (await response.json()).token;
  }

  const recordId = 'rec_123'; // your own record

  Lettrove.createEditor({
    container: '#editor',
    publishableKey: 'lt_pk_test_paste_yours_here',
    getToken,
    on: {
      'design:saved': ({ designId }) => {
        // Keep the design's id with your own record
        fetch(`/api/records/${recordId}`, {
          method: 'PATCH',
          body: JSON.stringify({ lettroveDesignId: designId }),
        });
      },
      error: ({ code, message }) => console.error('Lettrove', code, message),
    },
  }).then((editor) => {
    // Later, from anywhere:
    const stop = editor.on('selection:changed', ({ type }) => {
      console.log(type);
    });
    // stop(); // when you no longer want it
  });
</script>
```

## Step 2: what to do with each event

Each example below is written for `createEditor`. In React the same listener is the matching prop
(`onDesignUpdated`); in Vue the matching event (`@design-updated`).

### Keep the design's id (`design:saved`)

The `designId` is the whole link between your product and the design: open the editor with it
again, export it, read it from your server. Save it with your own record the first time it arrives;
it never changes afterwards.

```ts
on: {
  'design:saved': ({ designId, revision }) => {
    if (!myRecord.lettroveDesignId) {
      saveToMyRecord({ lettroveDesignId: designId });
    }
    console.log('revision', revision); // goes up by one with each save
  },
}
```

A new design has its id from the start, so `design:loaded` carries it too; `design:saved` is the
moment it is safe to rely on.

### "Saving… / Saved" (`design:updated` + `design:saved`)

Lettrove saves as your user works. Show it, so nobody wonders whether to click a save button that
is not there:

```ts
let status: 'saved' | 'saving' = 'saved';

on: {
  'design:updated': () => { status = 'saving'; render(); },
  'design:saved': () => { status = 'saved'; render(); },
}
```

In React, two props and a piece of state:

```tsx
const [status, setStatus] = useState<'saved' | 'saving'>('saved');

<LettroveEditor
  onDesignUpdated={() => setStatus('saving')}
  onDesignSaved={() => setStatus('saved')}
  // …
/>
```

### A live preview, or your own copy (`design:updated`)

`design:updated` carries the whole design as it is on screen, and what changed. A burst of edits is
one event, so reacting to it never slows typing down. To render it yourself, hand the design to
[`exportHtml`](../editor/exports.md) or your server; to keep your own copy, store it as it is: the
design document is yours to hold ([what you may rely on](../api/design-document.md)).

```ts
on: {
  'design:updated': ({ design, revision, change }) => {
    // change.kind: 'edit' | 'undo' | 'redo' | 'restore' | 'assistant'
    // change.blockId: the block changed, when the change was to one block
    myDraft = { design, revision };
    if (change.kind === 'assistant') showNote('The assistant changed it');
  },
}
```

To keep every design in your own database instead of ours, do not copy it from events: use
[your own design storage](../data/own-design-storage.md), which handles revisions and conflicts.

### Help for the selected block (`selection:changed`)

```ts
on: {
  'selection:changed': ({ blockId, type }) => {
    // type: 'heading' | 'text' | 'image' | 'button' | …
    // null when nothing is selected
    showHelpPanel(type ? `help/blocks/${type}` : 'help/getting-started');
  },
}
```

### Remember the theme (`theme:changed`)

When you keep the editor's own light / dark switch, your user's choice arrives here. Remember it
and pass it back as `appearance.theme` next time, so the editor opens as they left it:

```ts
on: {
  'theme:changed': ({ theme, applied }) => {
    // theme: what they picked, 'light' | 'dark' | 'auto'
    // applied: what is showing now, 'light' | 'dark'
    savePreference('lettroveTheme', theme);
  },
}

// Next time:
createEditor({
  appearance: { theme: loadPreference('lettroveTheme') ?? 'auto' },
  // …
});
```

With your own switch you set the theme yourself instead: see [Branding](./branding.md).

### Know when an image is uploaded (`image:uploaded`)

```ts
on: {
  'image:uploaded': ({ url, width, height, id }) => {
    console.log('uploaded', url, width, height, id);
  },
}
```

To keep uploads in your own storage, use [your own upload handler](../data/own-upload-handler.md):
this event then reports the address your handler returned.

### Find a mistake while building (`config:adjusted`)

An option you passed was dropped or changed: a typo with a live key, or something the project's
plan does not include ([options only narrow](../editor/options.md#options-only-narrow)). Log it
loudly in development. With a test key an invalid option stops the editor instead, with
`options_invalid`, so you find it before it ships.

```ts
on: {
  'config:adjusted': ({ option, reason }) => {
    console.warn(`Lettrove ignored ${option}: ${reason}`);
  },
}
```

### Show the right message (`error`)

Every error has a stable `code` ([error codes](../api/errors.md)) and a `message` in words.
`recoverable` is `true` when trying again can help and the editor keeps working (a token that
arrived late, say); when it is absent or `false`, the editor has stopped.

```ts
on: {
  error: ({ code, message, recoverable }) => {
    reportToMyLogs({ code, message });
    if (recoverable) return;
    if (code === 'token_expired' || code === 'token_invalid') {
      showMessage('Please sign in again.');
    } else if (code === 'quota_exceeded') {
      showMessage('This workspace is out of credits.');
    } else {
      showMessage('The editor could not open. We have been told.');
    }
  },
}
```

Decide on the `code`, never on the `message`: messages may be reworded, codes never change meaning.

### Wait for the editor (`ready`)

`createEditor` resolves at the same moment `ready` fires, so you rarely need the event itself.
It carries the editor `release` that opened, useful in your logs:

```ts
const editor = await createEditor({ /* … */ });
console.log('Lettrove editor', editor.release); // e.g. '1.0.0'
myToolbar.enable();
```

In React, `onReady={(editor) => …}` gives you the handle; in Vue, `@ready`.

### Notice an upgraded or newer design (`design:loaded`)

```ts
on: {
  'design:loaded': ({ designId, upgradedFrom, hasNewerBlocks }) => {
    // upgradedFrom: it was stored in an older format and upgraded as it opened
    // hasNewerBlocks: it holds blocks from a newer editor, kept as they are
    if (hasNewerBlocks) {
      showNote('Some blocks come from a newer editor and are kept as they are.');
    }
  },
}
```

## Listening later, and stopping

`editor.on(name, listener)` adds a listener at any time and returns a function that removes it:

```ts
const stop = editor.on('selection:changed', ({ type }) => showHelpFor(type));
// …
stop();
```

Listeners in `on` at open live as long as the editor. Destroying the editor (or unmounting the
React or Vue component) removes every listener.

## For your AI assistant

- Nine events: `ready`, `design:loaded`, `design:updated`, `design:saved`, `selection:changed`,
  `image:uploaded`, `theme:changed`, `config:adjusted`, `error`. Listen with `on: {…}` at open or
  `editor.on()` later (returns an unsubscribe function). React: `onX` props (`onDesignSaved`); Vue:
  kebab-case events (`@design-saved`); `onReady` / `@ready` receive the editor handle, `onError` /
  `@error` a `LettroveEmbedError`.
- Keep `designId` from `design:saved` with your own record; it is the link to the design.
- `design:updated` is debounced and carries the whole design; it is for previews and status, not
  for storing designs yourself (use `storage.designs: 'host'` for that).
- Decide on `error.code`, never on the message.
