apps/docs/content/sdk-features/events.mdx
The editor emits events for input, store changes, and lifecycle moments. Subscribe with editor.on() and unsubscribe with editor.off(). Use events to build analytics, sync external state, or extend editor behavior.
The Editor extends EventEmitter, and every event name and payload is typed by TLEventMap:
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
onMount={(editor) => {
editor.on('event', (info) => {
console.log('Event:', info.type, info.name)
})
}}
/>
</div>
)
}
Unsubscribe by calling off() with the same handler:
const handleEvent: TLEventMapHandler<'event'> = (info) => {
console.log('Event:', info.type)
}
editor.on('event', handleEvent)
editor.off('event', handleEvent)
Unsubscribe when the listener outlives the code that registered it. In React, return a cleanup function from your effect:
useEffect(() => {
const handleChange: TLEventMapHandler<'change'> = (entry) => {
console.log('Store changed:', entry.changes)
}
editor.on('change', handleChange)
return () => editor.off('change', handleChange)
}, [editor])
The event and before-event events fire for every event the editor dispatches: user input plus the misc events (cancel, complete, interrupt, tick) that tools use internally. Each receives a TLEventInfo object describing the event.
editor.on('event', (info) => {
if (info.type === 'pointer' && info.name === 'pointer_down') {
console.log('Clicked at', info.point)
}
})
before-event fires before the event reaches the tool state machine, and event fires after the tools have handled it.
Events have different types:
| Type | Names | Description |
|---|---|---|
pointer | pointer_down, pointer_move, pointer_up, right_click, middle_click, long_press | Mouse, touch, and pen interactions |
click | double_click | Double-click sequences |
keyboard | key_down, key_up, key_repeat | Keyboard input |
wheel | wheel | Scroll wheel and trackpad scrolling |
pinch | pinch_start, pinch, pinch_end | Two-finger pinch gestures |
misc | cancel, complete, interrupt, tick | Internal tool lifecycle events |
Pointer events include the target—what the pointer is over: canvas, shape, selection, handle, or overlay.
editor.on('event', (info) => {
if (info.type === 'pointer' && info.name === 'pointer_down') {
switch (info.target) {
case 'canvas':
console.log('Clicked empty canvas')
break
case 'shape':
console.log('Clicked shape:', info.shape.id)
break
case 'selection':
console.log('Clicked selection bounds')
break
case 'handle':
console.log('Clicked handle on:', info.shape.id)
break
case 'overlay':
console.log('Clicked overlay:', info.overlay.type)
break
}
}
})
Shape events fire from Editor#createShapes, Editor#updateShapes, and Editor#deleteShapes, just before the store is written. Changes from other sources (remote sync, undo/redo, direct store.put calls) don't fire them—use the change event for those.
| Event | Payload | Description |
|---|---|---|
created-shapes | TLRecord[] | Shapes about to be added |
edited-shapes | TLRecord[] | Shapes about to be updated |
deleted-shapes | TLShapeId[] | Shapes about to be removed |
edit | None | Fires alongside any of the above |
editor.on('created-shapes', (shapes) => {
console.log('Created:', shapes.length, 'shapes')
})
editor.on('deleted-shapes', (ids) => {
console.log('Deleted:', ids.length, 'shapes')
})
The change event fires whenever the store updates. It receives a HistoryEntry containing the diff and source:
editor.on('change', (entry) => {
const { added, updated, removed } = entry.changes
for (const record of Object.values(added)) {
if (record.typeName === 'shape') {
console.log('Added shape:', record.type)
}
}
for (const [from, to] of Object.values(updated)) {
if (from.typeName === 'shape') {
console.log('Updated shape:', from.id)
}
}
for (const record of Object.values(removed)) {
if (record.typeName === 'shape') {
console.log('Removed shape:', record.id)
}
}
})
The source property indicates where the change originated:
editor.on('change', (entry) => {
if (entry.source === 'user') {
// Change from local user interaction
scheduleAutosave()
} else if (entry.source === 'remote') {
// Change from collaboration sync
}
})
Two events fire on every animation frame, both carrying the milliseconds elapsed since the previous frame:
| Event | Payload | Description |
|---|---|---|
frame | number | Fires first; used internally (for example, for velocity) |
tick | number | Fires immediately after frame |
editor.on('tick', (elapsed) => {
// Update animations, physics, etc.
updateParticleSystem(elapsed)
})
These fire frequently (60+ times per second). Keep handlers fast to avoid dropping frames.
| Event | Payload | Description |
|---|---|---|
mount | None | Editor finished initializing |
unmount | None | Editor component unmounted |
dispose | None | Editor is being cleaned up |
crash | { error: unknown } | Editor encountered an error |
update | None | A store operation completed |
editor.on('mount', () => {
console.log('Editor ready')
})
editor.on('crash', ({ error }) => {
reportError(error)
})
| Event | Payload | Description |
|---|---|---|
resize | BoxModel | Viewport dimensions changed |
stop-camera-animation | None | Camera animation interrupted |
stop-following | None | Stopped following another user |
select-all-text | { shapeId: TLShapeId } | Editing started with all text selected |
place-caret | { shapeId, point } | Text caret positioned |
max-shapes | { name, pageId, count } | Page reached shape limit |
editor.on('resize', (bounds) => {
console.log('Canvas size:', bounds.w, 'x', bounds.h)
})
editor.on('max-shapes', ({ pageId, count }) => {
showWarning(`Page has reached the ${count} shape limit`)
})
The Tldraw component's onUiEvent prop captures high-level UI interactions separately from canvas events. This includes toolbar selections, menu actions, and keyboard shortcuts.
<Tldraw
onUiEvent={(name, data) => {
console.log('UI event:', name, data)
}}
/>
UI events track actions like selecting tools, grouping shapes, toggling dark mode, and zooming. They fire regardless of whether the action came from a click or keyboard shortcut. For the full list of events, see TLUiEventMap.
The change event is editor.store.listen() with no filters. For fine-grained control, call listen() directly:
const cleanup = editor.store.listen(
(entry) => {
// Handle changes
},
{ source: 'user', scope: 'all' }
)
// Later, unsubscribe
cleanup()
The listen() method accepts filter options:
source: 'user', 'remote', or 'all'—filter by change originscope: 'all', 'document', 'session', or 'presence'—filter by record scopeSee Side effects for registering handlers that can intercept and modify changes.