Skip to main content
Docs
Build with DeepSpace

Presence and cursors

Show who's online and broadcast live cursor, typing, and viewport state.

Show who else is in a room and where they're looking. DeepSpace exposes two distinct primitives for this.

  • usePresence - derived online/offline status from heartbeats stored on the users collection. Persistent. ~60s granularity.
  • usePresenceRoom - high-frequency ephemeral state (cursors, typing, viewport) broadcast through a dedicated Durable Object. In-memory only.

Online status#

usePresence reads lastSeenAt from the users collection in the current RecordScope and sends a heartbeat every 60 seconds. A user is "online" if their last heartbeat was within timeoutMs (default 5 minutes).

import { usePresence } from 'deepspace'

function OnlineList() {
  const { users, isOnline, getLastSeen } = usePresence()

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>
          <span className={isOnline(u.id) ? 'online' : 'offline'}></span>
          {u.name}
          {!isOnline(u.id) && getLastSeen(u.id) && (
            <small>last seen {new Date(getLastSeen(u.id)!).toLocaleString()}</small>
          )}
        </li>
      ))}
    </ul>
  )
}

Heartbeats are written to the users collection, so they're visible to anyone with read access to users. The data persists. Use usePresence for "currently online" lists where 60-second granularity is acceptable.

See usePresence for the full return shape and options.

Live cursors and typing#

For sub-second cursor or typing indicators, use usePresenceRoom. It connects to a dedicated PresenceRoom Durable Object that holds peer state in memory only - nothing is persisted, and disconnecting removes you from peers on every other client within a second.

import { usePresenceRoom } from 'deepspace'

function Canvas({ canvasId }: { canvasId: string }) {
  const { peers, updateState } = usePresenceRoom(`canvas:${canvasId}`)

  return (
    <div
      onMouseMove={(e) => updateState({ cursor: { x: e.clientX, y: e.clientY } })}
    >
      {peers.map((peer) => {
        const cursor = peer.state.cursor as { x: number; y: number } | undefined
        if (!cursor) return null
        return (
          <div
            key={peer.userId}
            style={{ position: 'absolute', left: cursor.x, top: cursor.y }}
          >
            {peer.userName}
          </div>
        )
      })}
    </div>
  )
}
peers excludes the current user. Identity fields (userId, userName, etc.) come from the verified JWT - peers can't spoof them. See usePresenceRoom for the full peer shape.

Scoping rooms#

The first argument to usePresenceRoom is any string. Two clients passing the same scope ID connect to the same PresenceRoom instance and see each other.

usePresenceRoom(`canvas:${canvasId}`)        // Per-canvas cursors
usePresenceRoom(`doc:${docId}`)              // Per-document presence
usePresenceRoom(`thread:${channelId}`)       // Per-channel typing

State merging#

updateState shallow-merges into your peer's state object, so you can broadcast multiple kinds of state independently:

updateState({ cursor: { x, y } })      // just cursor
updateState({ typing: true })          // just typing
updateState({ cursor: { x, y }, typing: true })  // both

To clear a state field, set it explicitly to false or null - omitting it leaves the previous value intact.

Throttling cursor updates#

onMouseMove fires far more often than you need to broadcast. Without throttling, you'll flood the room with messages. A requestAnimationFrame gate works:

const pending = useRef<{ x: number; y: number } | null>(null)
const scheduled = useRef(false)

function handleMove(e: React.MouseEvent) {
  pending.current = { x: e.clientX, y: e.clientY }
  if (scheduled.current) return
  scheduled.current = true
  requestAnimationFrame(() => {
    scheduled.current = false
    if (pending.current) updateState({ cursor: pending.current })
  })
}

For typing indicators, use a trailing timeout to clear the flag after the user stops typing.

Disconnects and reconnects#

The hook reconnects automatically. When the WebSocket drops, connected flips to false and your peer is removed from every other client's peers list. On reconnect you re-join with empty state - you must re-send your cursor or typing flag if you want it visible again. Use connected to dim or hide your own optimistic state during the gap.

User colors#

Cursor displays use a stable color per user.

import { getUserColor } from 'deepspace'

const color = getUserColor(peer.userId)  // deterministic hash → palette index

The same userId always returns the same color. Pass a custom palette for brand colors. See getUserColor.

Typing indicator#

import { usePresenceRoom } from 'deepspace'
import { useRef } from 'react'

function MessageInput({ channelId, value, onChange }: {
  channelId: string
  value: string
  onChange: (v: string) => void
}) {
  const { peers, updateState } = usePresenceRoom(`thread:${channelId}`)
  const typingTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)

  function handleChange(v: string) {
    onChange(v)
    updateState({ typing: true })
    clearTimeout(typingTimer.current)
    typingTimer.current = setTimeout(() => updateState({ typing: false }), 1500)
  }

  const typers = peers.filter((p) => p.state.typing).map((p) => p.userName)

  return (
    <>
      <input value={value} onChange={(e) => handleChange(e.target.value)} />
      {typers.length > 0 && <p>{typers.join(', ')} typing…</p>}
    </>
  )
}

Cursor overlay#

import { usePresenceRoom, getUserColor } from 'deepspace'

function CursorOverlay({ scope }: { scope: string }) {
  const { peers } = usePresenceRoom(scope)

  return (
    <>
      {peers.map((peer) => {
        const cursor = peer.state.cursor as { x: number; y: number } | undefined
        if (!cursor) return null
        const color = getUserColor(peer.userId)
        return (
          <div
            key={peer.userId}
            style={{
              position: 'absolute',
              left: cursor.x,
              top: cursor.y,
              pointerEvents: 'none',
              color,
            }}
          >
            <div
              style={{
                width: 8,
                height: 8,
                borderRadius: '50%',
                background: color,
              }}
            />
            <span>{peer.userName}</span>
          </div>
        )
      })}
    </>
  )
}

Choosing a primitive#

Primitive Use when Persistence
usePresenceRoom Cursors, typing, viewport pings, "active on this page" indicators In-memory, ephemeral
Yjs awareness (via useYjsField / useYjsRoom) Editor cursors and selections that must stay in sync with CRDT state over the same socket In-memory, ephemeral
useGameRoom Server-authoritative tick loops (player position, ready flags, game inputs) the server validates or simulates Server-validated

Next steps#