tldraw/tldraw
Store and migrations
Active contributors: Steve Ruiz, David Sheldrick
Purpose
How tldraw's reactive document state actually works: records, validators, side-effects, snapshots, and the migration system that keeps old documents loadable as the schema evolves.
The reactive store
graph LR
Editor -->|put / remove| Store
Store -->|fire before| Hooks1[before hooks]
Hooks1 --> Store
Store -->|update atoms| AtomMap
AtomMap -->|invalidate| Computed
Computed -->|notify| Subscribers
Store -->|emit diff| History[HistoryManager]
Store -->|emit diff| Sync[TLSyncClient]
Store -->|fire after| Hooks2[after hooks]The store is a typed map of records keyed by RecordId. Entries live in AtomMap instances so reads are tracked. Writes go through validation, fire side-effects, then mutate the atoms inside a transact so all downstream computeds invalidate together. The diff is emitted to listeners (history, sync, debug).
Records, scopes, and what syncs
Each record type has a scope (document | session | presence):
documentrecords persist to disk and sync across clients.TLShape,TLBinding,TLAsset,TLPage,TLDocument.sessionrecords are local to the editor instance.TLInstance,TLInstancePageState,TLPointer,TLCamera. They are still in the store, but never sent over the wire and never written to a.tldrsnapshot.presencerecords sync but are not persisted.TLInstancePresence.
The scope is set when the record type is defined (RecordType.createCustomRecordType({ scope: 'session' })).
The schema
createTLSchema() (in packages/tlschema/src/createTLSchema.ts) builds a StoreSchema from:
- The list of shape utils (from the SDK or your custom ones).
- The list of binding utils.
- The list of style props.
The schema knows about every record type and migration. Pass it to new Store({ schema, ... }) and the store can validate and migrate.
Migrations
Schema changes ship migrations:
- Record-level: defined inside the record file (e.g.,
packages/tlschema/src/shapes/TLArrowShape.ts). Versions are numbered; each migration takes a record from version N to N+1 (or back). - Store-level: defined in
packages/tlschema/src/store-migrations.ts. Used for changes that span records.
graph LR
OldSnapshot[snapshot v3] --> Migrate[migrate.ts runner]
Migrate -->|record migrations| Store
Migrate -->|store migrations| Store
Store --> NewState[live state at v5]Tests in packages/tlschema/src/migrations.test.ts (66 KB) snapshot every migration's behavior. Adding a new migration means adding a new test case here.
Side-effects
packages/store/src/lib/StoreSideEffects.ts exposes a registration API:
editor.store.sideEffects.registerBeforeChangeHandler(
'shape',
(prev, next, source) => {
// mutate `next` to enforce invariants (e.g., clamp x to grid)
}
);
editor.store.sideEffects.registerAfterCreateHandler(
'shape',
(record, source) => {
// react to a new record (e.g., create an undo entry)
}
);The editor uses these heavily — most of defaultSideEffects.ts is reaction code that keeps derived records (page state, presence) consistent with shape edits.
Snapshots
const snapshot = editor.store.getStoreSnapshot(); // serialize
editor.store.loadSnapshot(snapshot); // hydrate, run migrationsSnapshots include the schema version so cross-version loads run the right migrations. The dotcom and VS Code apps both round-trip via this mechanism for .tldr files.
Queries
store.query returns a StoreQueries instance. Common patterns:
store.query.records('shape')— reactive array of all records of a type.store.query.ids('shape', () => ({ parentId: { eq: pageId } }))— set of matching ids.store.query.exec('shape', { parentId: { eq: pageId } })— non-reactive snapshot.
The query AST is small — it supports equality, set membership, and a simple gt/lt predicate for indexed numeric fields. executeQuery.ts implements it.
Related
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.