> ## 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.

# 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`](/sdk-reference/client/realtime#usepresence-options) - derived online/offline status from heartbeats stored on the users collection. Persistent. \~60s granularity.
* [`usePresenceRoom`](/sdk-reference/client/realtime#usepresenceroom-scopeid) - 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).

```tsx theme={null}
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`](/sdk-reference/client/realtime#usepresence-options) 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.

```tsx theme={null}
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`](/sdk-reference/client/realtime#usepresenceroom-scopeid) 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.

```tsx theme={null}
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:

```tsx theme={null}
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:

```tsx theme={null}
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.

```tsx theme={null}
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`](/sdk-reference/client/realtime#user-colors).

## Typing indicator

```tsx theme={null}
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

```tsx theme={null}
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`](/sdk-reference/client/realtime#usepresenceroom-scopeid)                                            | Cursors, typing, viewport pings, "active on this page" indicators                                             | In-memory, ephemeral |
| Yjs `awareness` (via [`useYjsField`](/sdk-reference/client/realtime) / [`useYjsRoom`](/sdk-reference/client/realtime)) | Editor cursors and selections that must stay in sync with CRDT state over the same socket                     | In-memory, ephemeral |
| [`useGameRoom`](/sdk-reference/client/realtime#usegameroom-roomid)                                                     | Server-authoritative tick loops (player position, ready flags, game inputs) the server validates or simulates | Server-validated     |

## Next steps

* [Canvas](/guides/canvas) - real-time shapes and viewports on `useCanvas`.
* [Collaborative editing](/guides/collaborative-editing) - Yjs-backed text and rich data.
* [Real-time sync](/concepts/realtime-sync) - how sync works under the hood.
* [Realtime reference](/sdk-reference/client/realtime) - full hook signatures and return shapes.
