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.

Customizing multiplayer

Learn how to customize the multiplayer experience including presence data, sync behavior, and custom messaging.

Custom presence data

By default, tldraw shares cursor position and selection state. You can customize what presence data is shared.

Basic presence customization

import { useSync } from '@tldraw/sync'
import { TLStore, TLPresenceUserInfo, TLPresenceStateInfo } from 'tldraw'

function MyApp() {
  const store = useSync({
    uri: 'wss://sync.example.com/room',
    assets: myAssetStore,
    userInfo: {
      id: 'user-123',
      name: 'Alice',
      color: '#ff0000'
    },
    getUserPresence: (store: TLStore, user: TLPresenceUserInfo) => {
      // Get current instance state
      const instance = store.get('instance:instance')
      if (!instance) return null
      
      return {
        userId: user.id,
        userName: user.name,
        cursor: instance.cursor,
        selectedShapeIds: instance.selectedShapeIds,
        // Add custom fields
        currentTool: instance.currentToolId,
        viewportBounds: instance.viewportBounds
      }
    }
  })
  
  return <Tldraw store={store.store} />
}

Advanced presence example

import { useSync } from '@tldraw/sync'
import { computed } from '@tldraw/state'

function CollaborativeApp() {
  const store = useSync({
    uri: 'wss://sync.example.com/room',
    assets: myAssetStore,
    userInfo: {
      id: 'user-123',
      name: 'Alice',
      color: '#ff0000',
      avatar: 'https://example.com/avatar.jpg'
    },
    getUserPresence: (store, user) => {
      const instance = store.get('instance:instance')
      if (!instance) return null
      
      // Get current page
      const currentPageId = instance.currentPageId
      const page = store.get(currentPageId)
      
      return {
        userId: user.id,
        userName: user.name,
        userColor: user.color,
        cursor: instance.cursor,
        selectedShapeIds: instance.selectedShapeIds,
        // Custom fields
        currentPage: page?.name ?? 'Untitled',
        currentTool: instance.currentToolId,
        isIdle: instance.isIdle,
        lastActivity: Date.now()
      }
    }
  })
  
  return <Tldraw store={store.store} />
}

Reactive presence

Use signals for dynamic presence updates:
import { atom } from '@tldraw/state'
import { useSync } from '@tldraw/sync'

function MyApp() {
  // Create reactive user state
  const currentUser = atom('user', {
    id: 'user-123',
    name: 'Alice',
    color: '#ff0000',
    status: 'active' as 'active' | 'away' | 'busy'
  })
  
  const store = useSync({
    uri: 'wss://sync.example.com/room',
    assets: myAssetStore,
    // Pass reactive signal
    userInfo: currentUser,
    getUserPresence: (store, user) => {
      const instance = store.get('instance:instance')
      if (!instance) return null
      
      return {
        userId: user.id,
        userName: user.name,
        cursor: instance.cursor,
        selectedShapeIds: instance.selectedShapeIds,
        // Access reactive status
        status: user.status
      }
    }
  })
  
  // Update status dynamically
  const handleStatusChange = (status: 'active' | 'away' | 'busy') => {
    currentUser.update((user) => ({ ...user, status }))
  }
  
  return (
    <div>
      <Tldraw store={store.store} />
      <StatusSelector onChange={handleStatusChange} />
    </div>
  )
}

Hiding presence

Temporarily hide your presence from others:
const store = useSync({
  uri: 'wss://sync.example.com/room',
  assets: myAssetStore,
  getUserPresence: (store, user) => {
    // Return null to hide presence
    if (user.invisible) return null
    
    const instance = store.get('instance:instance')
    if (!instance) return null
    
    return {
      userId: user.id,
      userName: user.name,
      cursor: instance.cursor,
      selectedShapeIds: instance.selectedShapeIds
    }
  }
})

Custom messages

Send custom messages between clients:

Client-side sending

import { useSync } from '@tldraw/sync'
import { useEffect, useRef } from 'react'

function ChatApp() {
  const syncClientRef = useRef<any>(null)
  
  const store = useSync({
    uri: 'wss://sync.example.com/room',
    assets: myAssetStore,
    onCustomMessageReceived: (data) => {
      if (data.type === 'chat') {
        console.log(`${data.userName}: ${data.message}`)
        // Handle chat message
      }
      if (data.type === 'reaction') {
        console.log(`${data.userName} reacted with ${data.emoji}`)
        // Show reaction animation
      }
    }
  })
  
  // Access sync client to send messages
  useEffect(() => {
    if (store.status === 'synced-remote') {
      // Store reference to internal client for sending messages
      // Note: This is accessing internals and may change
    }
  }, [store])
  
  const sendChatMessage = (message: string) => {
    // Custom messages are sent through the sync client
    // You would need to access the internal TLSyncClient instance
  }
  
  return <Tldraw store={store.store} />
}

Server-side broadcasting

import { TLSocketRoom } from '@tldraw/sync-core'

const room = new TLSocketRoom({
  schema: createTLSchema(),
  storage: myStorage
})

// Send custom message to specific session
room.sendCustomMessage('session-123', {
  type: 'notification',
  title: 'Document saved',
  message: 'Your changes have been saved'
})

// Broadcast to all sessions (implement helper)
function broadcastCustomMessage(room: TLSocketRoom, data: any) {
  for (const [sessionId] of room.sessions) {
    room.sendCustomMessage(sessionId, data)
  }
}

broadcastCustomMessage(room, {
  type: 'announcement',
  message: 'Server will restart in 5 minutes'
})

Custom storage

Implement custom storage backends:
import { 
  TLSyncStorage, 
  TLSyncStorageTransaction 
} from '@tldraw/sync-core'
import { TLRecord } from '@tldraw/tlschema'

class RedisStorage implements TLSyncStorage<TLRecord> {
  private redis: RedisClient
  private roomId: string
  
  constructor(redis: RedisClient, roomId: string) {
    this.redis = redis
    this.roomId = roomId
  }
  
  async transaction<T>(
    fn: (txn: TLSyncStorageTransaction<TLRecord>) => T
  ) {
    // Implement transaction logic
    const clock = await this.redis.get(`room:${this.roomId}:clock`)
    const documentClock = await this.redis.get(
      `room:${this.roomId}:documentClock`
    )
    
    const txn: TLSyncStorageTransaction<TLRecord> = {
      get: async (id: string) => {
        const data = await this.redis.get(`room:${this.roomId}:doc:${id}`)
        return data ? JSON.parse(data) : undefined
      },
      set: async (id: string, record: TLRecord) => {
        await this.redis.set(
          `room:${this.roomId}:doc:${id}`,
          JSON.stringify(record)
        )
      },
      delete: async (id: string) => {
        await this.redis.del(`room:${this.roomId}:doc:${id}`)
      },
      getClock: () => parseInt(clock ?? '0', 10),
      getChangesSince: async (since: number) => {
        // Implement change tracking
        return null
      }
    }
    
    const result = fn(txn)
    
    return {
      result,
      clock: txn.getClock(),
      documentClock: parseInt(documentClock ?? '0', 10),
      changes: null
    }
  }
  
  onChange(callback: () => void) {
    // Subscribe to Redis pub/sub for external changes
    const channel = `room:${this.roomId}:changes`
    this.redis.subscribe(channel)
    this.redis.on('message', (ch, msg) => {
      if (ch === channel) callback()
    })
    
    return () => {
      this.redis.unsubscribe(channel)
    }
  }
}

Sync rate control

The sync system automatically adjusts frame rate:
  • 30 FPS when collaborating with others
  • 1 FPS when working solo
This is determined by presence mode:
import { atom } from '@tldraw/state'
import { useSync } from '@tldraw/sync'

function MyApp() {
  // Control presence mode
  const presenceMode = atom('presenceMode', 'full' as 'full' | 'solo')
  
  const store = useSync({
    uri: 'wss://sync.example.com/room',
    assets: myAssetStore,
    userInfo: myUserInfo,
    // Pass presence mode signal (internal API)
    // presenceMode affects sync rate internally
  })
  
  return <Tldraw store={store.store} />
}

Schema customization

Use custom schemas for extended functionality:
import { useSync } from '@tldraw/sync'
import { createTLSchema } from '@tldraw/tlschema'
import { MyCustomShapeUtil } from './MyCustomShape'

const customSchema = createTLSchema({
  shapes: {
    // Add custom shape types
    myCustom: MyCustomShapeUtil
  }
})

function MyApp() {
  const store = useSync({
    uri: 'wss://sync.example.com/room',
    assets: myAssetStore,
    schema: customSchema,
    shapeUtils: [MyCustomShapeUtil]
  })
  
  return <Tldraw store={store.store} />
}

Connection lifecycle hooks

React to connection events:
import { useSync } from '@tldraw/sync'
import { useEffect } from 'react'

function MyApp() {
  const store = useSync({
    uri: 'wss://sync.example.com/room',
    assets: myAssetStore
  })
  
  useEffect(() => {
    if (store.status === 'loading') {
      console.log('Connecting...')
    }
    
    if (store.status === 'synced-remote') {
      console.log('Connected and synced')
      
      // Track connection status changes
      if (store.connectionStatus === 'online') {
        console.log('Online')
      } else if (store.connectionStatus === 'offline') {
        console.log('Offline - will reconnect')
      }
    }
    
    if (store.status === 'error') {
      console.error('Sync error:', store.error)
    }
  }, [store.status, store.status === 'synced-remote' && store.connectionStatus])
  
  return <Tldraw store={store.store} />
}

Performance optimization

Batch updates

import { Editor } from 'tldraw'

function batchCreateShapes(editor: Editor, count: number) {
  // Use batch for better sync performance
  editor.batch(() => {
    for (let i = 0; i < count; i++) {
      editor.createShape({
        type: 'geo',
        x: i * 100,
        y: 100,
        props: { w: 80, h: 80 }
      })
    }
  })
}

Throttle rapid changes

import { useThrottle } from './hooks'

function MyComponent({ editor }: { editor: Editor }) {
  const handleDrag = useThrottle((x: number, y: number) => {
    editor.updateShape({
      id: 'shape-1',
      type: 'geo',
      x,
      y
    })
  }, 16) // ~60fps
  
  return <div onMouseMove={(e) => handleDrag(e.clientX, e.clientY)} />
}

Next steps

Client API

Complete client API reference

Server API

Server-side API reference

Store sync

Store synchronization reference