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
417 lines
12 KiB
Plaintext
417 lines
12 KiB
Plaintext
---
|
|
title: Signals
|
|
created_at: 12/28/2025
|
|
updated_at: 12/28/2025
|
|
keywords:
|
|
- signals
|
|
- reactive
|
|
- state
|
|
- atoms
|
|
- computed
|
|
- reactor
|
|
- hooks
|
|
status: published
|
|
date: 12/28/2025
|
|
readability: 9
|
|
voice: 9
|
|
completeness: 9
|
|
accuracy: 10
|
|
notes: ''
|
|
---
|
|
|
|
The tldraw SDK uses signals for state management. Signals automatically track dependencies: when state changes, only the parts of your application that depend on that state update.
|
|
|
|
The editor exposes many of its internal values as signals. Methods like `editor.getSelectedShapeIds()` and `editor.getCurrentPageShapes()` return reactive values that update automatically when the underlying state changes. The React bindings connect these signals to components. You don't need to manage subscriptions yourself.
|
|
|
|
The signals library lives in `@tldraw/state` and its React bindings in `@tldraw/state-react`. Both are re-exported from `tldraw`, so `import { atom, useValue, track } from 'tldraw'` also works.
|
|
|
|
## Core concepts
|
|
|
|
### Atoms
|
|
|
|
An atom holds a mutable value. When you change the value, any computeds or effects that depend on the atom will update.
|
|
|
|
```ts
|
|
import { atom } from '@tldraw/state'
|
|
|
|
const count = atom('count', 0)
|
|
|
|
count.get() // 0
|
|
count.set(5)
|
|
count.get() // 5
|
|
|
|
// Update based on current value
|
|
count.update((n) => n + 1)
|
|
count.get() // 6
|
|
```
|
|
|
|
The first argument is a name for debugging. The second is the initial value. Atoms accept an options object with `isEqual` (a custom equality function), `historyLength` (the number of diffs to retain for incremental updates), and `computeDiff` (a function to compute diffs between values, only used when `historyLength` is set).
|
|
|
|
### Computed values
|
|
|
|
A computed derives its value from other signals. It recomputes only when its dependencies change.
|
|
|
|
```ts
|
|
import { atom, computed } from '@tldraw/state'
|
|
|
|
const firstName = atom('firstName', 'Jane')
|
|
const lastName = atom('lastName', 'Doe')
|
|
|
|
const fullName = computed('fullName', () => {
|
|
return `${firstName.get()} ${lastName.get()}`
|
|
})
|
|
|
|
fullName.get() // 'Jane Doe'
|
|
|
|
firstName.set('John')
|
|
fullName.get() // 'John Doe' — recomputed automatically
|
|
```
|
|
|
|
Computed values are lazy: they don't calculate until you call `.get()`. They also cache their result. If you call `.get()` multiple times without any dependencies changing, the derivation function runs only once.
|
|
|
|
The `@computed` decorator provides the same functionality for class methods:
|
|
|
|
```ts
|
|
import { atom, computed } from '@tldraw/state'
|
|
|
|
class Counter {
|
|
count = atom('count', 0)
|
|
|
|
@computed getDoubled() {
|
|
return this.count.get() * 2
|
|
}
|
|
}
|
|
```
|
|
|
|
### Effects and reactors
|
|
|
|
Effects run side effects in response to signal changes. There are two ways to create them:
|
|
|
|
```ts
|
|
import { atom, react, reactor } from '@tldraw/state'
|
|
|
|
const count = atom('count', 0)
|
|
|
|
// react() starts immediately and returns a cleanup function
|
|
const stop = react('log count', () => {
|
|
console.log('Count is:', count.get())
|
|
})
|
|
|
|
count.set(1) // logs: Count is: 1
|
|
count.set(2) // logs: Count is: 2
|
|
|
|
stop() // Stop listening
|
|
|
|
// reactor() gives you control over when to start
|
|
const r = reactor('log count', () => {
|
|
console.log('Count is:', count.get())
|
|
})
|
|
|
|
r.start() // Begin listening
|
|
r.stop() // Stop listening
|
|
```
|
|
|
|
Effects track which signals they read and re-run when those signals change. The `scheduleEffect` option lets you batch updates, for example using `requestAnimationFrame`:
|
|
|
|
```ts
|
|
react(
|
|
'update-dom',
|
|
() => {
|
|
// DOM updates based on signal state
|
|
},
|
|
{
|
|
scheduleEffect: (execute) => requestAnimationFrame(execute),
|
|
}
|
|
)
|
|
```
|
|
|
|
### Transactions
|
|
|
|
Transactions batch multiple changes into a single update. Effects only run once after all changes complete:
|
|
|
|
```ts
|
|
import { atom, react, transact } from '@tldraw/state'
|
|
|
|
const a = atom('a', 1)
|
|
const b = atom('b', 2)
|
|
|
|
react('sum', () => {
|
|
console.log('Sum:', a.get() + b.get())
|
|
})
|
|
|
|
// Without transaction: effect runs twice
|
|
a.set(10) // logs: Sum: 12
|
|
b.set(20) // logs: Sum: 30
|
|
|
|
// With transaction: effect runs once
|
|
transact(() => {
|
|
a.set(100)
|
|
b.set(200)
|
|
})
|
|
// logs: Sum: 300
|
|
```
|
|
|
|
The `transaction` function supports rollback:
|
|
|
|
```ts
|
|
import { transaction } from '@tldraw/state'
|
|
|
|
transaction((rollback) => {
|
|
a.set(999)
|
|
if (somethingWentWrong) {
|
|
rollback() // Restores original values
|
|
}
|
|
})
|
|
```
|
|
|
|
## React integration
|
|
|
|
The `@tldraw/state-react` package connects signals to React components.
|
|
|
|
### useValue
|
|
|
|
The most common hook. It reads a signal value and subscribes the component to changes:
|
|
|
|
```tsx
|
|
import { atom } from '@tldraw/state'
|
|
import { useValue } from '@tldraw/state-react'
|
|
|
|
const count = atom('count', 0)
|
|
|
|
function Counter() {
|
|
const value = useValue(count)
|
|
return <div>Count: {value}</div>
|
|
}
|
|
```
|
|
|
|
You can also compute a value inline with a dependency array:
|
|
|
|
```tsx
|
|
function ShapeInfo({ editor }) {
|
|
const selectedCount = useValue('selected count', () => editor.getSelectedShapeIds().length, [
|
|
editor,
|
|
])
|
|
return <div>{selectedCount} shapes selected</div>
|
|
}
|
|
```
|
|
|
|
### track
|
|
|
|
The `track` higher-order component automatically tracks signal access during render:
|
|
|
|
```tsx
|
|
import { track } from '@tldraw/state-react'
|
|
|
|
const Counter = track(function Counter() {
|
|
return <div>Count: {count.get()}</div>
|
|
})
|
|
```
|
|
|
|
Tracked components re-render when any signal accessed during render changes. This is the pattern used throughout tldraw's internal components. The component is also wrapped in `React.memo`, so it only re-renders when props change or tracked signals update.
|
|
|
|
### useAtom and useComputed
|
|
|
|
Create component-local signals that persist across renders. Reading `.get()` during render only subscribes the component if it's wrapped in `track` (otherwise read the value with `useValue`), so wrap the component:
|
|
|
|
```tsx
|
|
import { track, useAtom, useComputed } from '@tldraw/state-react'
|
|
|
|
const Counter = track(function Counter() {
|
|
const count = useAtom('count', 0)
|
|
const doubled = useComputed('doubled', () => count.get() * 2, [count])
|
|
|
|
return (
|
|
<div>
|
|
<button onClick={() => count.update((n) => n + 1)}>Increment</button>
|
|
<div>Count: {count.get()}</div>
|
|
<div>Doubled: {doubled.get()}</div>
|
|
</div>
|
|
)
|
|
})
|
|
```
|
|
|
|
### useReactor and useQuickReactor
|
|
|
|
Run effects tied to component lifecycle. The effect automatically tracks which signals it reads:
|
|
|
|
```tsx
|
|
import { useReactor, useQuickReactor } from '@tldraw/state-react'
|
|
|
|
function SelectionLogger({ editor }) {
|
|
// Throttled to next animation frame — good for DOM updates
|
|
useReactor(
|
|
'update title',
|
|
() => {
|
|
const count = editor.getSelectedShapeIds().length
|
|
document.title = `${count} shapes selected`
|
|
},
|
|
[editor]
|
|
)
|
|
|
|
// Runs immediately — for critical state synchronization
|
|
useQuickReactor(
|
|
'sync selection',
|
|
() => {
|
|
syncSelectionToServer(editor.getSelectedShapeIds())
|
|
},
|
|
[editor]
|
|
)
|
|
|
|
return null
|
|
}
|
|
```
|
|
|
|
### useStateTracking
|
|
|
|
Lower-level hook for manual tracking. This is what `track` uses internally:
|
|
|
|
```tsx
|
|
import { useStateTracking } from '@tldraw/state-react'
|
|
|
|
function CustomComponent() {
|
|
return useStateTracking('CustomComponent', () => {
|
|
return <div>{someSignal.get()}</div>
|
|
})
|
|
}
|
|
```
|
|
|
|
## Signals in the editor
|
|
|
|
The editor uses signals extensively. Most getter methods return reactive values:
|
|
|
|
```tsx
|
|
function SelectionInfo({ editor }) {
|
|
const selectedShapes = useValue('shapes', () => editor.getSelectedShapes(), [editor])
|
|
const currentPage = useValue('page', () => editor.getCurrentPage(), [editor])
|
|
const zoomLevel = useValue('zoom', () => editor.getZoomLevel(), [editor])
|
|
|
|
return (
|
|
<div>
|
|
<div>Page: {currentPage.name}</div>
|
|
<div>Zoom: {Math.round(zoomLevel * 100)}%</div>
|
|
<div>{selectedShapes.length} shapes selected</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
You can use `track` for cleaner syntax when accessing many signals:
|
|
|
|
```tsx
|
|
const SelectionInfo = track(function SelectionInfo({ editor }) {
|
|
const shapes = editor.getSelectedShapes()
|
|
const page = editor.getCurrentPage()
|
|
const zoom = editor.getZoomLevel()
|
|
|
|
return (
|
|
<div>
|
|
<div>Page: {page.name}</div>
|
|
<div>Zoom: {Math.round(zoom * 100)}%</div>
|
|
<div>{shapes.length} shapes selected</div>
|
|
</div>
|
|
)
|
|
})
|
|
```
|
|
|
|
## Debugging
|
|
|
|
The `whyAmIRunning` function helps trace what triggered an update. Call it inside an effect or computed to see which signals changed:
|
|
|
|
```ts
|
|
import { atom, react, whyAmIRunning } from '@tldraw/state'
|
|
|
|
const name = atom('name', 'Bob')
|
|
|
|
react('greeting', () => {
|
|
whyAmIRunning()
|
|
console.log('Hello', name.get())
|
|
})
|
|
|
|
name.set('Alice')
|
|
// Console output:
|
|
// Effect(greeting) is executing because:
|
|
// ↳ Atom(name) changed
|
|
```
|
|
|
|
For nested dependencies, the output shows the full chain:
|
|
|
|
```ts
|
|
const firstName = atom('firstName', 'Jane')
|
|
const lastName = atom('lastName', 'Doe')
|
|
const fullName = computed('fullName', () => `${firstName.get()} ${lastName.get()}`)
|
|
|
|
react('log name', () => {
|
|
whyAmIRunning()
|
|
console.log(fullName.get())
|
|
})
|
|
|
|
firstName.set('John')
|
|
// Console output:
|
|
// Effect(log name) is executing because:
|
|
// ↳ Computed(fullName) changed
|
|
// ↳ Atom(firstName) changed
|
|
```
|
|
|
|
All signals have a `name` property (the first argument when creating them) that appears in debug output.
|
|
|
|
## Reading without tracking
|
|
|
|
Sometimes you want to read a signal's value without creating a dependency. Use `unsafe__withoutCapture` to read signals without triggering re-runs:
|
|
|
|
```ts
|
|
import { atom, react, unsafe__withoutCapture } from '@tldraw/state'
|
|
|
|
const name = atom('name', 'Sam')
|
|
const time = atom('time', Date.now())
|
|
|
|
// Update time every second
|
|
setInterval(() => time.set(Date.now()), 1000)
|
|
|
|
react('log name changes', () => {
|
|
// Only re-run when name changes, not when time changes
|
|
const currentTime = unsafe__withoutCapture(() => time.get())
|
|
console.log(name.get(), 'was changed at', currentTime)
|
|
})
|
|
```
|
|
|
|
## API reference
|
|
|
|
### @tldraw/state
|
|
|
|
| Export | Description |
|
|
| ------------------------------------ | -------------------------------------------------------------- |
|
|
| `atom(name, value, options)` | Create a mutable signal |
|
|
| `computed(name, fn, options)` | Create a derived signal |
|
|
| `@computed` | Decorator for computed class methods |
|
|
| `react(name, fn, options)` | Run an effect immediately, returns cleanup function |
|
|
| `reactor(name, fn, options)` | Create a controllable effect with `start()` and `stop()` |
|
|
| `transact(fn)` | Batch changes into a single update |
|
|
| `transaction(fn)` | Batch changes with rollback support |
|
|
| `isAtom(value)` | Type guard for atoms |
|
|
| `isSignal(value)` | Type guard for any signal |
|
|
| `getComputedInstance(o, p)` | Get the underlying computed for a `@computed` decorated method |
|
|
| `whyAmIRunning()` | Debug helper to trace update triggers |
|
|
| `unsafe__withoutCapture(fn)` | Read signals without creating dependencies |
|
|
| `RESET_VALUE` | Symbol returned by `getDiffSince` when history is insufficient |
|
|
| `isUninitialized(value)` | Check if a computed is running its first derivation |
|
|
| `withDiff(value, diff)` | Manually provide a diff when returning from a computed |
|
|
| `localStorageAtom(name, v, options)` | Returns `[atom, cleanup]` tuple; atom persists to localStorage |
|
|
|
|
### @tldraw/state-react
|
|
|
|
| Export | Description |
|
|
| ---------------------------------- | ------------------------------------------- |
|
|
| `useValue(signal)` | Subscribe to a signal, returns its value |
|
|
| `useValue(name, fn, deps)` | Compute and subscribe to a derived value |
|
|
| `useAtom(name, initialValue)` | Create a component-local atom |
|
|
| `useComputed(name, fn, deps)` | Create a component-local computed |
|
|
| `useReactor(name, fn, deps)` | Effect throttled to animation frames |
|
|
| `useQuickReactor(name, fn, deps)` | Effect that runs immediately |
|
|
| `useStateTracking(name, renderFn)` | Manual signal tracking for render functions |
|
|
| `track(Component)` | HOC that tracks signal access during render |
|
|
|
|
## Related examples
|
|
|
|
- [Signals](/examples/events/signals) - Subscribing to store values and running side effects with `track`, `useValue`, and `useReactor`.
|
|
- [Reactive inputs](/examples/editor-api/reactive-inputs) - Using `useValue` with editor input state.
|