Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.deep.space/llms.txt

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

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

PrimitiveUse whenPersistence
usePresenceRoomCursors, typing, viewport pings, “active on this page” indicatorsIn-memory, ephemeral
Yjs awareness (via useYjsField / useYjsRoom)Editor cursors and selections that must stay in sync with CRDT state over the same socketIn-memory, ephemeral
useGameRoomServer-authoritative tick loops (player position, ready flags, game inputs) the server validates or simulatesServer-validated

Next steps