PolynomialHQdocs
Guides

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.

Loading the presence demo…

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:

FieldTypeMeaning
connectionIdstringUnique identifier for this browser connection
userIdstringStable application user ID from the room token
presenceTPresenceLatest 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 connectionId for rendering connection instances.
  • Set cursors and transient selections to null when the pointer leaves.
  • Do not use presence for comments, document content, or anything that must survive a disconnect.

On this page