Files
upstream-tldraw/apps/docs/content/sdk-features/store.mdx
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

413 lines
15 KiB
Plaintext

---
title: Store
created_at: 12/29/2025
updated_at: 12/29/2025
keywords:
- store
- records
- state
- persistence
- snapshots
- queries
- schema
status: published
date: 12/29/2025
readability: 9
voice: 9
completeness: 9
accuracy: 10
notes: ''
---
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.
## 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:
```ts
{
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.
### Record scopes
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.
## Custom record types
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.)
```tsx
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:
```ts
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
}
}
```
### Creating and reading custom records
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:
```ts
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.
### Custom record migrations
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:
```ts
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](/examples/data/assets/custom-records) for a complete app.
## Basic operations
### Reading records
The store provides reactive and non-reactive access to records:
```ts
// 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.
### Creating and updating records
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:
```ts
// 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:
```ts
editor.store.update(shapeId, (shape) => ({
...shape,
x: shape.x + 50,
}))
```
### Deleting records
```ts
// Remove specific records
editor.store.remove([shapeId])
// Clear everything
editor.store.clear()
```
## Listening to changes
Subscribe to store changes with [Store#listen](?). The callback receives a diff describing what changed:
```ts
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()
```
### Filtering listeners
You can filter by source and scope:
```ts
// 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](/sdk-features/side-effects) instead. Side effects are lifecycle hooks that can intercept and modify records during operations.
## Snapshots
Snapshots serialize the store for persistence or transfer.
### Saving state
```ts
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.
### Loading state
```ts
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:
```ts
// Load just the document
loadSnapshot(editor.store, { document: saved.document })
// Later, restore session state
loadSnapshot(editor.store, { session: saved.session })
```
### Initial state
Pass a snapshot to the `Tldraw` component to initialize with saved data:
```tsx
function App() {
return <Tldraw snapshot={savedSnapshot} />
}
```
### Migrations
Snapshots include schema version information. When you load a snapshot from an older schema version, the store migrates it automatically:
```ts
// 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](/sdk-features/persistence#Migrations) for details.
## Queries
The store provides indexed queries for efficient lookups through [Store#query](?):
```ts
// 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.
```ts
// 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.
## Transactions
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:
```ts
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](/sdk-features/history) for the undo/redo options `run` accepts.
## Computed caches
For expensive derived data, use [Store#createComputedCache](?):
```ts
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.
## Creating a standalone store
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](?):
```ts
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.
## Related examples
- [Store events](/examples/events/store-events) - Listening to store changes and displaying them in real time.
- [Snapshots](/examples/editor-api/snapshots) - Saving and loading editor state with `getSnapshot` and `loadSnapshot`.
- [Local storage](/examples/data/assets/local-storage) - Persisting to localStorage with throttled saves.
- [Custom records](/examples/data/assets/custom-records) - Registering and rendering a custom record type.