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#
- Canvas - real-time shapes and viewports on
useCanvas. - Collaborative editing - Yjs-backed text and rich data.
- Real-time sync - how sync works under the hood.
- Realtime reference - full hook signatures and return shapes.