App

The App class is the main control class for tldraw's editor. You can use it to manage the editor's internal state, make changes to the document, or respond to changes that have occurred.

By design, the App's surface area is very large. While that makes it difficult to fully document here, the general rule is that everything is available via the App. Need to create some shapes? Use app.createShapes(). Need to delete them? Use app.deleteShapes(). Need a sorted array of every shape on the current page? Use app.sortedShapesArray.

Rather than document everything, this page is intended to give a broad idea of how the App class is organized and some of the architectural concepts involved.

State

The app holds the raw state of the document in its store property. Data is kept here as a table of JSON serializable records.

For example, the store contains a page record for each page in the current document, as well as an instancePageState record for each page that stores information about the editor's state for that page, and a single instanceState for each editor instance which stores the id of the user's current page.

The app also exposes many computed values which are derived from other records in the store. For example, app.selectedIds is a computed property that will return the editor's current selected shape ids for its current page.

You can use these properties directly or you can use them in signia signals.

import { track } from "@tldraw/signia"
import { useApp } from "@tldraw/tldraw"

export const SelectedIdsCount = track(() => {
  const app = useApp()
  
  return (
    <div>{app.selectedIds.length}</div>
  )
})

Changing the state

The App class has many methods for updating its state. For example, you can change the current page's selection using app.setSelectedIds. You can also use other convenience methods, such as app.select, app.deselect, app.selectAll, or app.selectNone.

app.selectNone()
app.select(myShapeId, myOtherShapeId)
app.selectedIds // [myShapeId, myOtherShapeId]

Each change to the state happens within a transaction. You can batch changes into a single transaction using the app.batch method. It's a good idea to batch wherever possible, as this reduces the overhead for persisting or distributing those changes.

Listening for changes

You can subscribe to changes using app.store.listen. Each time a transaction completes, the app will call the callback with a history entry. This entry contains information about the records that were added, changed, or deleted, as well as whether the change was caused by the user or from a remote change.

app.store.listen(entry => {
  console.log(entry) // { changes, source }
})

Remote changes

By default, changes to the editor's store are assumed to have come from the editor itself. You can use app.store.mergeRemoteChanges to make changes in the store that will be emitted via store.listen with the source property as 'remote'.

If you're setting up some kind of multiplayer backend, you would want to send only the 'user' changes to the server and merge the changes from the server using app.store.mergeRemoteChanges. (We'll have more information about this soon.)

Undo and redo

The history stack in tldraw contains two types of data: "marks" and "commands". Commands have their own undo and redo methods that describe how the state should change when the command is undone or redone.

You can call app.mark(id) to add a mark to the history stack with the given id.

When you call app.undo(), the app will undo each command until it finds either a mark or the start of the stack. When you call app.redo(), the app will redo each command until it finds either a mark or the end of the stack.

// A
app.mark("duplicate everything")
app.selectAll()
app.duplicateShapes(app.selectedIds)
// B

app.undo() // will return to A
app.redo() // will return to B

You can call app.bail() to undo and delete all commands in the stack until the first mark.

// A
app.mark("duplicate everything")
app.selectAll()
app.duplicateShapes(app.selectedIds)
// B

app.bail() // will return to A
app.redo() // will do nothing 

You can use app.bailToMark(id) to undo and delete all commands and marks until you reach a mark with the given id.

// A
app.mark("first")
app.selectAll()
// B
app.mark("second")
app.duplicateShapes(app.selectedIds)
// C

app.bailToMark("first") // will to A

Events and Tools

The App class receives events from the user interface via its dispatch method. When the App receives an event, it is first handled internally to update app.inputs and other state before, and then sent into to the app's state chart.

You shouldn't need to use the dispatch method directly, however you may write code in the state chart that responds to these events.

State Chart

The App class has a "state chart", or a tree of StateNode instances, that contain the logic for the app's tools such as the select tool or the draw tool. User interactions such as moving the cursor will produce different changes to the state depending on which nodes are active.

Each node be active or inactive. Each state node may also have zero or more children. When a state is active, and if the state has children, one (and only one) of its children must also be active. When a state node receives an event from its parent, it has the opportunity to handle the event before passing the event to its active child. The node can handle an event in any way: it can ignore the event, update records in the store, or run a transition that changes which states nodes are active.

When a user interaction is sent to the app via its dispatch method, this event is sent to the app's root state node (app.root) and passed then down through the chart's active states until either it reaches a leaf node or until one of those nodes produces a transaction.

A diagram showing an event being sent to the app and handled in the state chart.

Path

You can get the app's current "path" of active states via app.root.path. In the above example, the value would be "root.select.idle".

You can check whether a path is active via app.isIn, or else check whether multiple paths are active via app.isInAny.

app.store.path // 'root.select.idle'

app.isIn('root.select') // true
app.isIn('root.select.idle') // true
app.isIn('root.select.pointing_shape') // false
app.isInAny('app.select.idle', 'app.select.pointing_shape') // true

Note that the paths you pass to isIn or isInAny can be the full path or a partial of the start of the path. For example, if the full path is root.select.idle, then isIn would return true for the paths root, root.select, or root.select.idle.

If all you're interested in is the state below root, there is a convenience property, app.currentToolId, that can help with the app's currently selected tool.

import { track } from "@tldraw/signia"
import { useApp } from "@tldraw/tldraw"

export const CreatingBubbleToolUi = track(() => {
  const app = useApp()

  const isSelected = app.isIn('root.bubble.creating')

  if (!app.currentToolId === 'bubble') return
  
  return (
    <div data-isSelected={isSelected}>Creating Bubble</div>
  )
})

Inputs

The app's inputs object holds information about the user's current input state, including their cursor position (in page space and screen space), which keys are pressed, what their multi-click state is, and whether they are dragging, pointing, pinching, and so on.

Note that the modifier keys include a short delay after being released in order to prevent certain errors when modeling interactions. For example, when a user releases the "Shift" key, app.inputs.shiftKey will remain true for another 100 milliseconds or so.

This property is stored as regular data. It is not reactive.

Common things to do with the app

Create shapes

app.createShapes([
  {
    id,
    type: 'geo',
    x: 0,
    y: 0,
    props: {
      geo: 'rectangle',
      w: 100,
      h: 100,
      dash: 'draw',
      color: 'blue',
      size: 'm',
    },
  },
])

Update shapes

const shape = app.selectedShapes[0]

app.updateShapes([
  {
    id: shape.id, // required
    type: shape.type, // required
    x: 100,
    y: 100,
    props: {
      w: 200,
    },
  },
])

Delete shapes

const shape = app.selectedShapes[0]

app.deleteShapes([shape.id])

Get a shape by its id

app.getShapeById(myShapeId)

Move the camera

app.setCamera(0, 0, 1)

See the tldraw-examples repository for an example of how to use tldraw's App API to control the editor.

Edit this page
Last edited on 22 March 2023
UsageShapes