Back to Tldraw

Side effects

apps/docs/content/sdk-features/side-effects.mdx

5.4.05.7 KB
Original Source

Side effects are lifecycle hooks that run when records are created, updated, or deleted. You can use them to intercept and modify records, validate changes, or react to completed operations by updating related data.

The editor uses side effects internally to keep data consistent. When you delete a shape, the editor automatically removes its bindings. When a binding changes, its BindingUtil gets a chance to update the connected shapes. These hooks let independent parts of the system stay in sync without being directly coupled. Side effects live on the store and are exposed as Editor#sideEffects, a StoreSideEffects instance.

How it works

Before and after handlers

Side effects provide six handler types organized around three operations: create, change, and delete. Each operation has a "before" and "after" phase. Handlers are registered per record typeName ('shape', 'binding', 'page', or a custom type); check shape.type inside the handler if you only care about one kind of shape.

HandlerRunsReturn value
beforeCreateBefore a record is storedThe record to store. Return a modified copy to change it.
beforeChangeBefore an update is storedThe record to store. Return prev to block the change, or a modified next.
beforeDeleteBefore a record is removedfalse to prevent deletion.
afterCreateAfter a record is storedNothing. Update other records in response.
afterChangeAfter an update is storedNothing. Update other records in response.
afterDeleteAfter a record is removedNothing. Clean up references or cascade deletions.

Use before handlers to modify the record being operated on, and after handlers to update other records in response. Returning prev from beforeChange blocks the change because the store sees no difference from what's already stored and skips the write.

Source tracking

Every handler receives a source parameter indicating whether the change came from user interaction ('user') or remote synchronization ('remote'). This lets you handle local and synced changes differently:

typescript
editor.sideEffects.registerAfterCreateHandler('shape', (shape, source) => {
	if (source === 'user') {
		logUserAction('created shape', shape.type)
	}
})

You might auto-save only after user operations, or skip validation for trusted remote data.

Registration and cleanup

Register side effects using the type-specific methods on editor.sideEffects. Each method returns a cleanup function you can call to remove the handler:

typescript
const cleanup = editor.sideEffects.registerAfterCreateHandler('shape', (shape, source) => {
	if (shape.type === 'note') {
		editor.updateShape({ id: shape.id, type: 'note', meta: { createdAt: Date.now() } })
	}
})

// Later, when no longer needed
cleanup()

To register several handlers at once, pass an object keyed by type name to editor.sideEffects.register({ shape: { afterCreate, beforeDelete } }); it returns a single cleanup function.

Execution order

Handlers execute in registration order. If one handler reads a value another handler writes, register the writer first.

Before handlers run inline as each record is written. After handlers are queued and run when the outermost store operation completes, so changes made inside them belong to the same transaction and the same undo step. If an after handler makes further changes, their handlers run in a follow-up pass. The operationComplete handler runs once at the end, after every pass has finished.

Use cases

Constraining shape positions

Before handlers can enforce constraints on records. This example blocks moves into negative coordinates by returning the previous record:

typescript
editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {
	if (next.x < 0 || next.y < 0) {
		return prev // Block the change by returning the previous record
	}
	return next
})

Cascading deletions

You can cascade deletions to related records. This example deletes a frame when its last child is removed:

typescript
editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {
	const parent = editor.getShape(shape.parentId)
	if (parent && parent.type === 'frame') {
		const siblings = editor.getSortedChildIdsForParent(parent.id)
		if (siblings.length === 0) {
			editor.deleteShape(parent.id)
		}
	}
})

Batch processing with operationComplete

The operationComplete handler runs once after all changes in a transaction finish. Use it for expensive operations that should happen once per batch rather than on every record change:

typescript
editor.sideEffects.registerOperationCompleteHandler((source) => {
	if (source === 'user') {
		scheduleAutosave()
	}
})