Back to Tldraw

Events

apps/docs/content/sdk-features/events.mdx

5.4.09.1 KB
Original Source

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.

Subscribing to events

The Editor extends EventEmitter, and every event name and payload is typed by TLEventMap:

tsx
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:

tsx
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:

tsx
useEffect(() => {
	const handleChange: TLEventMapHandler<'change'> = (entry) => {
		console.log('Store changed:', entry.changes)
	}

	editor.on('change', handleChange)
	return () => editor.off('change', handleChange)
}, [editor])

Event categories

Input events

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.

tsx
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:

TypeNamesDescription
pointerpointer_down, pointer_move, pointer_up, right_click, middle_click, long_pressMouse, touch, and pen interactions
clickdouble_clickDouble-click sequences
keyboardkey_down, key_up, key_repeatKeyboard input
wheelwheelScroll wheel and trackpad scrolling
pinchpinch_start, pinch, pinch_endTwo-finger pinch gestures
misccancel, complete, interrupt, tickInternal tool lifecycle events

Pointer events include the target—what the pointer is over: canvas, shape, selection, handle, or overlay.

tsx
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

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.

EventPayloadDescription
created-shapesTLRecord[]Shapes about to be added
edited-shapesTLRecord[]Shapes about to be updated
deleted-shapesTLShapeId[]Shapes about to be removed
editNoneFires alongside any of the above
tsx
editor.on('created-shapes', (shapes) => {
	console.log('Created:', shapes.length, 'shapes')
})

editor.on('deleted-shapes', (ids) => {
	console.log('Deleted:', ids.length, 'shapes')
})

Store changes

The change event fires whenever the store updates. It receives a HistoryEntry containing the diff and source:

tsx
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:

tsx
editor.on('change', (entry) => {
	if (entry.source === 'user') {
		// Change from local user interaction
		scheduleAutosave()
	} else if (entry.source === 'remote') {
		// Change from collaboration sync
	}
})

Frame events

Two events fire on every animation frame, both carrying the milliseconds elapsed since the previous frame:

EventPayloadDescription
framenumberFires first; used internally (for example, for velocity)
ticknumberFires immediately after frame
tsx
editor.on('tick', (elapsed) => {
	// Update animations, physics, etc.
	updateParticleSystem(elapsed)
})

These fire frequently (60+ times per second). Keep handlers fast to avoid dropping frames.

Lifecycle events

EventPayloadDescription
mountNoneEditor finished initializing
unmountNoneEditor component unmounted
disposeNoneEditor is being cleaned up
crash{ error: unknown }Editor encountered an error
updateNoneA store operation completed
tsx
editor.on('mount', () => {
	console.log('Editor ready')
})

editor.on('crash', ({ error }) => {
	reportError(error)
})

UI and camera events

EventPayloadDescription
resizeBoxModelViewport dimensions changed
stop-camera-animationNoneCamera animation interrupted
stop-followingNoneStopped 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
tsx
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`)
})

UI events

The Tldraw component's onUiEvent prop captures high-level UI interactions separately from canvas events. This includes toolbar selections, menu actions, and keyboard shortcuts.

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

Listening to store changes directly

The change event is editor.store.listen() with no filters. For fine-grained control, call listen() directly:

tsx
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 origin
  • scope: 'all', 'document', 'session', or 'presence'—filter by record scope

See Side effects for registering handlers that can intercept and modify changes.

  • Canvas events - Log pointer, keyboard, and wheel events as you interact with the canvas.
  • Store events - Track shape creation, updates, and deletion through store change events.
  • UI events - Capture high-level UI interactions like tool selection and menu actions.