apps/docs/content/sdk-features/store.mdx
The store is tldraw's reactive database. It holds all shapes, pages, bindings, assets, and other records that make up your document. The store is reactive: when data changes, the UI updates automatically. It validates all records against a schema and tracks every change for undo/redo, persistence, and synchronization.
In most cases you won't interact with the store directly. The editor wraps it with higher-level methods like Editor#createShapes and Editor#getCurrentPageShapes. But understanding the store helps when you need snapshots for persistence, want to listen for changes, or need direct access to records.
Everything in the store is a record. A record is a JSON object with an id and a typeName. Here's what a shape record looks like:
{
id: 'shape:abc123',
typeName: 'shape',
type: 'geo',
x: 100,
y: 200,
props: {
geo: 'rectangle',
w: 300,
h: 150,
color: 'blue',
},
// ... other fields
}
The id is a branded string that includes the type prefix (shape:, page:, binding:). This prevents accidentally mixing up IDs from different record types.
Records have a scope that determines how they're persisted and synchronized:
| Scope | Persisted | Synced to other users | Example |
|---|---|---|---|
document | Yes | Yes | Shapes, pages, bindings |
session | Optional | No | Current page, camera position |
presence | No | Yes | Cursor positions, user selection |
Document records are your actual drawing data, saved to storage and synced across instances. Session records are local to one editor instance, like which page you're viewing. Presence records sync to other users in real time but aren't saved. They're for showing cursors and selections in multiplayer.
Shapes, bindings, and assets cover most drawing use cases, but some data doesn't fit any of them, like comments attached to a shape or per-user annotations. Register a custom record type and these records live in the store like any other: validated, migrated, persisted, and synced according to the scope you pick.
Pass record definitions to createTLStore under the records option, then give that store to the Tldraw component. (Under the hood this calls createTLSchema with the same records option, so you can also build a schema directly.)
import { createTLStore, T, Tldraw } from 'tldraw'
const store = createTLStore({
records: {
comment: {
scope: 'document',
validator: T.object({
id: T.string,
typeName: T.literal('comment'),
shapeId: T.string,
authorId: T.string,
text: T.string,
createdAt: T.number,
}),
createDefaultProperties: () => ({ createdAt: Date.now() }),
},
},
})
function App() {
return <Tldraw store={store} />
}
For multiplayer, pass the same records option to useSync. Type names must not collide with tldraw's built-in types (shape, page, asset, and so on); createTLSchema throws if they do.
Each entry is a CustomRecordInfo:
| Field | Type | Description |
|---|---|---|
scope | 'document' | 'session' | document for synced and persisted data, session for local-only data. Custom presence-scoped types aren't supported by tldraw sync, which allows only one presence type per schema. |
validator | T.Validatable | Validates the full record (including id and typeName) on every write. |
createDefaultProperties | () => Record<string, unknown> | Optional. Default properties used when you build a record with store.schema.types.<name>.create(). |
migrations | MigrationSequence | TLPropsMigrations | Optional. Schema evolution for the record type. An empty sequence is created automatically if you omit it. |
For type-safe IDs and TypeScript narrowing across your app, augment TLGlobalRecordPropsMap so the SDK's TLRecord union knows about your record types:
import { BaseRecord, RecordId } from 'tldraw'
interface TLComment extends BaseRecord<'comment', RecordId<TLComment>> {
shapeId: string
authorId: string
text: string
createdAt: number
}
declare module 'tldraw' {
export interface TLGlobalRecordPropsMap {
comment: TLComment
}
}
Use createCustomRecordId to mint IDs that match the typeName:suffix convention the store expects. It returns a generic record ID, so cast it to your own ID type. The companion guards isCustomRecordId and isCustomRecord check the type name but return plain booleans; they don't narrow the TypeScript type, so cast after checking:
import { createCustomRecordId, isCustomRecord, RecordId } from 'tldraw'
const commentId = createCustomRecordId('comment') as RecordId<TLComment>
editor.store.put([
{
id: commentId,
typeName: 'comment',
shapeId: 'shape:abc123',
authorId: 'user:alice',
text: 'Looks good',
createdAt: Date.now(),
},
])
for (const record of editor.store.allRecords()) {
if (isCustomRecord('comment', record)) {
console.log((record as TLComment).text)
}
}
Custom records appear in store.listen diffs and participate in undo/redo when written through the usual store APIs. editor.store.query.records('comment') gives you a typed, reactive list of them. Sync clients include document-scoped records in the synced document automatically.
Define migrations the same way you would for shape props. Use createCustomRecordMigrationIds to generate the canonical com.tldraw.<type>/<version> IDs, then createCustomRecordMigrationSequence for the sequence itself:
import { createCustomRecordMigrationIds, createCustomRecordMigrationSequence } from 'tldraw'
const commentVersions = createCustomRecordMigrationIds('comment', {
AddAuthorId: 1,
})
const commentMigrations = createCustomRecordMigrationSequence({
sequence: [
{
id: commentVersions.AddAuthorId,
up: (record) => ({ ...record, authorId: record.authorId ?? 'unknown' }),
down: ({ authorId, ...rest }) => rest,
},
],
})
Pass commentMigrations as the migrations field on the comment's CustomRecordInfo and the store will run them when loading older snapshots. See the custom records example for a complete app.
The store provides reactive and non-reactive access to records:
// Reactive — creates a dependency, component will re-render when record changes
const shape = editor.store.get(shapeId)
// Non-reactive — for hot paths where you don't want re-renders
const shape = editor.store.unsafeGetWithoutCapture(shapeId)
// Check if a record exists
const exists = editor.store.has(shapeId)
// Get all records
const allRecords = editor.store.allRecords()
The reactive Store#get integrates with tldraw's signals system. When you call it inside a tracked component or computed, the component re-renders when that record changes.
The Store#put method handles both creation and updates. A put with a new id creates the record; a put with an existing id replaces it. For shapes, Editor#createShape fills in the required fields for you, so you'll usually reach for put when updating:
// Create a shape through the editor, which fills in defaults
editor.createShape({ id: shapeId, type: 'geo', x: 0, y: 0 })
// Update an existing record (put with same id)
const shape = editor.store.get(shapeId)!
editor.store.put([{ ...shape, x: 100 }])
The Store#update helper is more convenient for single-record updates:
editor.store.update(shapeId, (shape) => ({
...shape,
x: shape.x + 50,
}))
// Remove specific records
editor.store.remove([shapeId])
// Clear everything
editor.store.clear()
Subscribe to store changes with Store#listen. The callback receives a diff describing what changed:
const cleanup = editor.store.listen((entry) => {
// Records that were created
for (const record of Object.values(entry.changes.added)) {
console.log('Added:', record.typeName, record.id)
}
// Records that were updated [before, after]
for (const [prev, next] of Object.values(entry.changes.updated)) {
console.log('Updated:', next.id)
}
// Records that were deleted
for (const record of Object.values(entry.changes.removed)) {
console.log('Removed:', record.id)
}
})
// Stop listening
cleanup()
You can filter by source and scope:
// Only listen to user changes (not remote sync)
editor.store.listen(handleChanges, { source: 'user', scope: 'all' })
// Only document records
editor.store.listen(handleChanges, { source: 'all', scope: 'document' })
The source indicates where the change came from: 'user' for local edits, 'remote' for synchronized changes from other users.
To keep data internally consistent, like cleaning up bindings when a shape is deleted, use side effects instead. Side effects are lifecycle hooks that can intercept and modify records during operations.
Snapshots serialize the store for persistence or transfer.
import { getSnapshot } from 'tldraw'
// Get a snapshot of document and session state
const { document, session } = getSnapshot(editor.store)
// Save to storage
localStorage.setItem('my-drawing', JSON.stringify({ document, session }))
The document snapshot contains shapes, pages, bindings, and assets: everything that makes up the drawing itself. The session snapshot contains per-instance state like the current page and camera position.
For multiplayer apps, you typically save document state to your server and session state per-user locally.
import { loadSnapshot } from 'tldraw'
const saved = JSON.parse(localStorage.getItem('my-drawing'))
loadSnapshot(editor.store, saved)
See getSnapshot and loadSnapshot for more details.
You can load document and session separately:
// Load just the document
loadSnapshot(editor.store, { document: saved.document })
// Later, restore session state
loadSnapshot(editor.store, { session: saved.session })
Pass a snapshot to the Tldraw component to initialize with saved data:
function App() {
return <Tldraw snapshot={savedSnapshot} />
}
Snapshots include schema version information. When you load a snapshot from an older schema version, the store migrates it automatically:
// Migrate a snapshot without loading it
const migrated = editor.store.migrateSnapshot(oldSnapshot)
The migration system handles schema changes between tldraw versions. You can also define migrations for custom shape props and custom record types. See persistence for details.
The store provides indexed queries for efficient lookups through Store#query:
// Create an index by property value
const shapesByParent = editor.store.query.index('shape', 'parentId')
// Get all shapes with a specific parent
const childShapes = shapesByParent.get().get(frameId) ?? new Set()
Indexes are reactive computed values. They update automatically when records change and track dependencies like any other signal.
// Filter by type and query expression
const textShapes = editor.store.query.records('shape', () => ({
type: { eq: 'text' },
}))
// Get all records of a type
const allShapes = editor.store.query.records('shape')
Query expressions support eq (equals), neq (not equals), and gt (greater than, for numbers). The records() method returns a computed array that updates when matching records change, while index() returns a computed map from property values to sets of record IDs.
Batch multiple changes with Editor#run. Changes inside the callback are applied together, side effects see a consistent state, and the whole batch becomes one undo step:
editor.run(() => {
editor.store.put([shape1, shape2])
editor.store.update(shape3Id, (s) => ({ ...s, x: 100 }))
editor.store.remove([shape4Id])
})
Store listeners are already batched: the store collects changes and notifies listeners once per animation frame, squashing adjacent changes from the same source. See history for the undo/redo options run accepts.
For expensive derived data, use Store#createComputedCache:
const boundsCache = editor.store.createComputedCache('shape-bounds', (shape: TLShape) => {
return calculateBounds(shape)
})
// Get cached value (recalculates only when shape changes)
const bounds = boundsCache.get(shapeId)
The cache lazily computes values when accessed and invalidates them when the underlying record changes. This is how the editor efficiently maintains shape bounds, geometry, and other derived data.
Most of the time you use the store through the editor. But you can create a standalone store for testing or headless scenarios using createTLStore:
import { createTLStore, loadSnapshot } from 'tldraw'
// Create a store and load saved data
const store = createTLStore()
loadSnapshot(store, savedSnapshot)
// Pass the pre-loaded store to Tldraw
function App() {
return <Tldraw store={store} />
}
Creating your own store is useful when you need to load data before mounting the editor, share a store between multiple components, or work with tldraw data without rendering the editor at all.
getSnapshot and loadSnapshot.