Skip to main content

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 lists every field of every event; this page shows what to do with them.

The nine events​

EventWhenUse it for
readyThe editor is on screen and answers callsEnabling your own buttons; reading which release opened
design:loadedA design opened, new or existingKnowing the id from the start; noticing an upgrade from an older format
design:updatedThe design changed (a burst of edits is one event)"Saving…", a live preview, your own copy of the design
design:savedThe design was saved (Lettrove saves as your user works)Keeping the design's id with your own record; "Saved"
selection:changedThe selected block changed, or nothing is selectedHelp or controls for the kind of block selected
image:uploadedAn upload is ready to useLogging, your own count of images
theme:changedYour user used the editor's light / dark switchRemembering their choice for next time
config:adjustedAn option you passed was dropped or changedFinding a mistake while building
errorSomething went wrong that you may want to show or logShowing a message by code; logging

In the order they happen in a session:

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.

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:

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.

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.

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:

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:

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 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).

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, which handles revisions and conflicts.

Help for the selected block (selection:changed)​

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:

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.

Know when an image is uploaded (image:uploaded)​

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: 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). 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.

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

Show the right message (error)​

Every error has a stable code (error codes) 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.

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:

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)​

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:

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.