Presence and collaborators
Model cursors, selections, and other ephemeral per-connection state.
Presence describes a user connection right now. It is synchronized to everyone else in the room and disappears when that connection leaves.
Define presence
Presence must be a JSON object. Keep it small and focused on the current interaction:
type Presence = {
cursor: { x: number; y: number } | null;
name: string;
selectedBlockId: string | null;
tool: "select" | "draw" | "text";
};Set the initial value when entering the room:
<RoomProvider<Presence, Storage>
id={`canvas:${canvasId}`}
initialPresence={{
cursor: null,
name: currentUser.name,
selectedBlockId: null,
tool: "select",
}}
initialStorage={{ blocks: [] }}
>
<Canvas />
</RoomProvider>Read and update your presence
useMyPresence() returns the current presence and an update function. Updates are shallow
patches, so fields you omit keep their current value.
import { useMyPresence } from "@polynomialhq/react";
function ToolPicker() {
const [presence, updatePresence] = useMyPresence<Presence>();
return (
<button
aria-pressed={presence.tool === "draw"}
onClick={() => updatePresence({ tool: "draw" })}
type="button"
>
Draw
</button>
);
}Use useUpdateMyPresence() when a component only writes presence:
import { useUpdateMyPresence } from "@polynomialhq/react";
function CursorTracker() {
const updatePresence = useUpdateMyPresence<Presence>();
return (
<div
onPointerLeave={() => updatePresence({ cursor: null })}
onPointerMove={(event) => {
const bounds = event.currentTarget.getBoundingClientRect();
updatePresence({
cursor: {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
},
});
}}
/>
);
}Every updatePresence() call sends one presence.update frame. Pointer events fire far more
often than a screen refresh, so throttle the handler — for example with
requestAnimationFrame — before shipping a cursor layer.
Render collaborators
useOthers() returns one entry per other connection:
import { useOthers } from "@polynomialhq/react";
function Cursors() {
const others = useOthers<Presence>();
return others.map((other) => {
if (!other.presence.cursor) {
return null;
}
return (
<div
key={other.connectionId}
style={{
transform: `translate(${other.presence.cursor.x}px, ${other.presence.cursor.y}px)`,
}}
>
{other.presence.name}
</div>
);
});
}Each RoomOther contains:
| Field | Type | Meaning |
|---|---|---|
connectionId | string | Unique identifier for this browser connection |
userId | string | Stable application user ID from the room token |
presence | TPresence | Latest presence for the connection |
One user can have multiple connections if they open more than one tab or device. The list is
sorted by connectionId, so it is stable between renders.
A collaborator only appears in useOthers() once they have sent presence at least once. A
connection that joins and never calls updatePresence() stays invisible, which is why setting a
meaningful initialPresence matters.
Read the current user
useSelf() returns the joined connection, or null before the room joins:
const self = useSelf<Presence>();
return <span>{self ? `Connected as ${self.id}` : "Connecting…"}</span>;self.info is currently always an empty object. The userInfo you attach to a room token is
carried in the token claims and is available to your backend, but it is not yet surfaced to the
browser. Put anything the interface must render — display name, avatar — into presence.
Framework-agnostic client
The core room exposes the same operations without React:
const { leave, room } = client.enterRoom<Presence, Storage>("canvas:demo", {
initialPresence,
initialStorage,
});
await room.connect();
room.updatePresence({ cursor: { x: 120, y: 80 } });
const unsubscribe = room.subscribe((event) => {
if (event.type === "others.changed") {
renderCursors(event.snapshot.others);
}
});Call unsubscribe() when the consumer is disposed and leave() when the application no longer
needs the room. leave() disconnects the room and removes it from client.getRoom().
Design guidance
- Store cursor positions relative to the collaborative surface, not the viewport. Percentages survive a resize; pixels do not.
- Use stable user IDs for identity and
connectionIdfor rendering connection instances. - Set cursors and transient selections to
nullwhen the pointer leaves. - Do not use presence for comments, document content, or anything that must survive a disconnect.