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