Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/tldraw/tldraw/llms.txt

Use this file to discover all available pages before exploring further.

The @tldraw/state package provides a powerful reactive signals system that manages all state in the tldraw SDK. Understanding signals is essential for building performant, reactive features.

What are signals?

Signals are reactive values that automatically track dependencies and notify observers when they change. The system consists of three core primitives:
  • Atoms: Mutable state containers
  • Computed: Derived values that update automatically
  • Reactions: Side effects that run when dependencies change
import { atom, computed, react } from '@tldraw/state'

// Atom: mutable state
const count = atom('count', 0)

// Computed: derived value
const doubled = computed('doubled', () => count.get() * 2)

// Reaction: side effect
const stop = react('logger', () => {
  console.log(`Count is ${count.get()}, doubled is ${doubled.get()}`)
})

count.set(5)
// Logs: "Count is 5, doubled is 10"

stop() // Clean up the reaction
All editor state in tldraw is managed through signals, from the current selection to the camera position to custom shape properties.

Atoms

Atoms are the foundation of the reactive system. They hold mutable state and notify dependents when changed.

Creating atoms

import { atom } from '@tldraw/state'

const name = atom('name', 'John')
const count = atom('count', 0)
const items = atom('items', ['a', 'b', 'c'])
The first parameter is a debug name (useful for debugging), and the second is the initial value.

Reading and writing

// Get the current value
const currentName = name.get() // 'John'

// Set a new value
name.set('Jane')

// Update based on current value
count.update(n => n + 1)
Atom values should be immutable. When updating arrays or objects, create new instances rather than mutating:
// ❌ Bad: mutates the array
items.update(arr => {
  arr.push('d')
  return arr
})

// ✅ Good: creates new array
items.update(arr => [...arr, 'd'])

Advanced atom options

Atoms can be configured with custom equality checks and history tracking:
import { atom } from '@tldraw/state'

const position = atom(
  'position',
  { x: 0, y: 0 },
  {
    // Custom equality to avoid unnecessary updates
    isEqual: (a, b) => a.x === b.x && a.y === b.y,
    
    // Track history for time-travel debugging
    historyLength: 100,
    
    // Compute diffs between values
    computeDiff: (prev, next) => ({
      dx: next.x - prev.x,
      dy: next.y - prev.y,
    }),
  }
)

Computed signals

Computed signals derive their values from other signals and automatically update when dependencies change.

Basic usage

import { atom, computed } from '@tldraw/state'

const firstName = atom('firstName', 'John')
const lastName = atom('lastName', 'Doe')

const fullName = computed('fullName', () => {
  return `${firstName.get()} ${lastName.get()}`
})

console.log(fullName.get()) // "John Doe"

firstName.set('Jane')
console.log(fullName.get()) // "Jane Doe"

Lazy evaluation

Computed signals use lazy evaluation - they only recalculate when accessed AND dependencies have changed:
let computeCount = 0

const expensive = computed('expensive', () => {
  computeCount++
  console.log('Computing...')
  return count.get() * 2
})

count.set(5)
count.set(10)
count.set(15)
console.log(computeCount) // 0 - not computed yet!

expensive.get() // Logs "Computing..." once
console.log(computeCount) // 1 - computed when accessed
This lazy evaluation is key to tldraw’s performance. Expensive computations only run when their results are actually needed.

Incremental computation

Computed signals can access their previous value for efficient incremental updates:
import { computed, isUninitialized, withDiff } from '@tldraw/state'

const items = atom('items', [1, 2, 3])

const sum = computed('sum', (prevValue, lastComputedEpoch) => {
  const currentItems = items.get()
  
  if (isUninitialized(prevValue)) {
    // First computation
    return currentItems.reduce((a, b) => a + b, 0)
  }
  
  // Incremental update based on diff
  const diff = items.getDiffSince(lastComputedEpoch)
  // ... use diff to update sum incrementally
  
  return newSum
})

Computed with custom equality

const bounds = computed(
  'bounds',
  () => {
    const shapes = selectedShapes.get()
    return calculateBounds(shapes)
  },
  {
    isEqual: (a, b) => {
      return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h
    },
  }
)

Decorator syntax

Use the @computed decorator for class properties:
import { atom, computed } from '@tldraw/state'

class Counter {
  count = atom('count', 0)
  max = 100
  
  @computed
  getRemaining() {
    return this.max - this.count.get()
  }
  
  @computed({ historyLength: 10 })
  getPercentage() {
    return (this.count.get() / this.max) * 100
  }
}

const counter = new Counter()
console.log(counter.getRemaining()) // 100
counter.count.set(30)
console.log(counter.getRemaining()) // 70

Reactions

Reactions run side effects when their dependencies change.

Using react()

import { atom, react } from '@tldraw/state'

const count = atom('count', 0)

const stop = react('update-title', () => {
  document.title = `Count: ${count.get()}`
})

count.set(5) // Title updates to "Count: 5"
count.set(10) // Title updates to "Count: 10"

stop() // Stop reacting to changes

Scheduled reactions

Defer reactions to animation frames for better performance:
import { react } from '@tldraw/state'

let scheduled = false
const scheduleEffect = (execute: () => void) => {
  if (!scheduled) {
    scheduled = true
    requestAnimationFrame(() => {
      scheduled = false
      execute()
    })
  }
}

const stop = react(
  'render',
  () => {
    // Heavy rendering work
    renderCanvas(shapes.get())
  },
  { scheduleEffect }
)

EffectScheduler

For more control over reactions, use EffectScheduler:
import { EffectScheduler } from '@tldraw/state'

const scheduler = new EffectScheduler(
  'my-effect',
  (lastReactedEpoch) => {
    console.log('Effect running')
    console.log('Count:', count.get())
  },
  {
    scheduleEffect: (execute) => {
      requestAnimationFrame(execute)
    },
  }
)

scheduler.attach() // Start listening
scheduler.execute() // Run immediately

// Later...
scheduler.detach() // Stop listening

Transactions

Transactions batch multiple updates into a single reactive cycle:
import { atom, react, transact } from '@tldraw/state'

const x = atom('x', 0)
const y = atom('y', 0)

let reactionCount = 0
react('logger', () => {
  reactionCount++
  console.log(`Position: ${x.get()}, ${y.get()}`)
})

// Without transaction: reaction runs twice
x.set(10)
y.set(20)
console.log(reactionCount) // 3 (initial + 2 updates)

// With transaction: reaction runs once
transact(() => {
  x.set(100)
  y.set(200)
})
console.log(reactionCount) // 4 (only 1 more update)
The editor automatically batches most operations in transactions, so you rarely need to use transact() manually when working with editor methods.

Transaction abort

Transactions can be rolled back:
import { transaction } from '@tldraw/state'

const name = atom('name', 'John')

const txn = transaction((rollback) => {
  name.set('Jane')
  
  if (someCondition) {
    rollback() // Reverts to 'John'
    return
  }
  
  name.set('Alice')
})

Using signals in React

The @tldraw/state-react package provides React hooks for signals:
import { useValue, useAtom, useComputed } from '@tldraw/state-react'
import { atom } from '@tldraw/state'

const count = atom('count', 0)

function Counter() {
  // Subscribe to atom changes
  const currentCount = useValue('count', () => count.get(), [])
  
  return (
    <div>
      <p>Count: {currentCount}</p>
      <button onClick={() => count.set(count.get() + 1)}>
        Increment
      </button>
    </div>
  )
}
See the React integration docs for more details.

Editor state examples

Here’s how the editor uses signals internally:

Selected shapes

// Inside the Editor class
this._selectedShapeIds = atom('selectedShapeIds', new Set<TLShapeId>())

this.getSelectedShapes = computed('selectedShapes', () => {
  const ids = this._selectedShapeIds.get()
  return Array.from(ids).map(id => this.store.get(id)).filter(Boolean)
})

Viewport culling

this.getCurrentPageRenderingShapes = computed('renderingShapes', () => {
  const shapes = this.getCurrentPageShapes()
  const viewportBounds = this.getViewportPageBounds()
  
  return shapes.filter(shape => {
    const util = this.getShapeUtil(shape)
    if (!util.canCull(shape)) return true
    
    const bounds = this.getShapePageBounds(shape)
    return bounds && viewportBounds.includes(bounds)
  })
})

Camera following

react('follow-user', () => {
  const followingUserId = this.getFollowingUserId()
  if (!followingUserId) return
  
  const presence = this.store.get(followingUserId)
  if (!presence) return
  
  this.setCamera(presence.camera, { animation: { duration: 200 } })
})

Performance best practices

Avoid unnecessary dependencies

// ❌ Bad: depends on entire array when only length matters
const hasItems = computed('hasItems', () => {
  return items.get().length > 0
})

// ✅ Better: store length separately if checked frequently
const itemCount = computed('itemCount', () => items.get().length)
const hasItems = computed('hasItems', () => itemCount.get() > 0)

Use custom equality for objects

const settings = atom(
  'settings',
  { theme: 'light', fontSize: 14 },
  {
    isEqual: (a, b) => {
      return a.theme === b.theme && a.fontSize === b.fontSize
    },
  }
)

Debounce expensive reactions

import { debounce } from '@tldraw/utils'

const debouncedSave = debounce((data: string) => {
  localStorage.setItem('data', data)
}, 1000)

react('auto-save', () => {
  const data = editor.getSnapshot()
  debouncedSave(JSON.stringify(data))
})

Debugging signals

Use whyAmIRunning() to debug unexpected recomputations:
import { computed, whyAmIRunning } from '@tldraw/state'

const expensive = computed('expensive', () => {
  whyAmIRunning() // Logs dependency tree
  return somethingExpensive()
})
expensive is running because:
  - selectedShapeIds changed (epoch 42 -> 43)
    - last value: Set { 'shape1' }
    - new value: Set { 'shape1', 'shape2' }

Next steps

Architecture

Understand the overall system architecture

Performance

Optimize your tldraw application

Store

Learn about the reactive store

React integration

Use signals in React components