gridcn1.2.2

Multiplayer presence

Paint remote users' selections into the grid with zero core re-renders.

Multiplayer presence lives in the separate data-grid-presence add-on.

Click a header to sort: Ada's and Grace's highlights are fixed to view positions and jump around, but Linus's 2×2 range is tied to its row ids and keeps following the same rows — a local filter that splits the range paints one labeled rect per fragment.

Name
Email
Age
Role
Score
Silent Tiger
user0@example.com
63
Viewer
4
Quick Lion
user1@example.com
67
Viewer
89
Swift Wolf
user2@example.com
40
Manager
80
Eager Lion
user3@example.com
50
User
51
Eager Tiger
user4@example.com
45
Viewer
83
Silent Wolf
user5@example.com
24
User
15
Swift Lion
user6@example.com
34
Viewer
62
Eager Lion
user7@example.com
21
Manager
42
Eager Eagle
user8@example.com
25
Editor
35
Bold Tiger
user9@example.com
19
Editor
52
Bold Bear
user10@example.com
42
User
28
Swift Wolf
user11@example.com
47
Manager
9
Swift Bear
user12@example.com
41
Viewer
45
Eager Wolf
user13@example.com
35
Editor
44
Swift Eagle
user14@example.com
65
Viewer
51
Quick Lion
user15@example.com
22
Manager
28
Bold Wolf
user16@example.com
30
Editor
76
Swift Eagle
user17@example.com
19
Admin
26
Eager Eagle
user18@example.com
35
Manager
70
Eager Bear
user19@example.com
33
Editor
67
Silent Lion
user20@example.com
48
Manager
89
Silent Tiger
user21@example.com
62
Manager
40
Eager Tiger
user22@example.com
51
Manager
82
Silent Lion
user23@example.com
26
Admin
16
pnpm dlx shadcn@latest add @gridcn/data-grid-presence

API

import { useDataGridPresence } from "@/components/data-grid-presence/data-grid-presence";

type PresenceHighlight = {
  /** Must be unique per entry — a multi-range selection sends one entry per range, each with its own id. */
  id: string;
  /** Any CSS color; painted at fixed alpha for the fill, full opacity for the border/chip. */
  color: string;
  /** View-space rect (same coordinate system as GridSelection.current.range). */
  range: GridRect;
  /** Optional name chip anchored at the range's top-left corner, only while it's on-window. */
  label?: string;
};

type RowIdPresenceHighlight = {
  id: string;
  color: string;
  /** Resolved to a view-row by the plugin itself; dropped silently if filtered out of view. */
  rowId: string;
  columnId: string;
  label?: string;
};

type RowIdRangePresenceHighlight = {
  id: string;
  color: string;
  /** Resolved per row by the plugin itself; ids filtered out of view are dropped silently. */
  rowIds: string[];
  /** Resolved per column by the plugin itself; hidden/unknown ids are dropped (dev-warned once). */
  columnIds: string[];
  label?: string;
};

setPresenceHighlights takes an array of any of the three forms, mixed freely. The id must be unique per entry. A user with a multi-range selection sends one entry per range, each with its own id (each entry paints one rect, or one rect per contiguous fragment for the range form). See Coordinates below.

Wiring it up

The add-on is one plugin: its Overlay paints the highlights, its Mount dev-warns once when rows or columns move under an active view-index entry. The plugin renders into the same overlay layer that selection and the fill preview use, and receives a ctx with the same window-clamp and pin-zone segmentation helpers that the built-in overlays of the core use. See Plugins for the full seam, including every ctx field and a worked example of a plugin from scratch.

useDataGridPresence() is a single hook. Call it EXACTLY ONCE per grid, in your own component ABOVE where you render <DataGridProvider>. The plugins prop lives on the provider, so the plugin has to exist before it. Every further call mints ANOTHER store: only the plugin wired into plugins paints, so a second instance's setPresenceHighlights writes into a store nothing reads. If other components need the setter or the read-back storeApi, pass them down as props from the single call:

"use client";

import { useEffect } from "react";
import { DataGrid } from "@/components/data-grid/data-grid";
import { useDataGridPresence } from "@/components/data-grid-presence/data-grid-presence";

function MyGrid() {
  const { plugin, setPresenceHighlights } = useDataGridPresence();

  useEffect(() => {
    const socket = connectToRoom(roomId);
    socket.on("presence", (remoteHighlights) => setPresenceHighlights(remoteHighlights));
    return () => socket.close();
  }, [setPresenceHighlights, roomId]);

  return <DataGrid data={data} columns={columns} getRowId={getRowId} plugins={[plugin]} />;
}

setPresenceHighlights(highlights) is the real, imperative mechanism. Call it directly from your transport handler: a websocket message, a CRDT awareness update, or a polling tick. The identity of plugin never changes across renders, and the grid compares the plugins list entry-wise, so the inline [plugin] array is stable enough as-is.

setPresenceHighlights REPLACES the whole list (snapshot semantics): each call must carry the full current set of remote entries, not a diff: the natural shape of a websocket "presence state" message or a CRDT awareness snapshot. For a transport that sends diffs instead, the per-entry lifecycle hooks cover the common cases:

const { plugin, setPresenceHighlights, removePresenceHighlight, clearPresenceHighlights } = useDataGridPresence();

socket.on("leave", (leftId) => removePresenceHighlight(leftId)); // entry ids ARE the peer ids
socket.on("room-closed", clearPresenceHighlights);              // the room empties

removePresenceHighlight(id) removes the one entry whose id matches and leaves every other entry untouched; it is a no-op when no entry matches. A peer with a multi-range selection holds one entry per range. For that peer, either key every range entry by the same peer id and remove each, or resend the filtered snapshot as before. clearPresenceHighlights() removes every entry. Both re-render DataGridOverlays and nothing else, the same as a snapshot call.

The same composition works with the core's DataGrid convenience wrapper (see the note on Fill handle).

Zero re-render guarantee

Calling setPresenceHighlights re-renders DataGridOverlays and nothing else: no row, no cell, and no aria-selected change. If you need a read-back subscription elsewhere, pass storeApi into useDataGridPresenceHighlights(storeApi). It re-renders only the component that calls it.

Coordinates: rowId-native, or view-index

A remote peer's cell is identified by { rowId, columnId }, not a view position:

// remote peer sends { userId, color, rowId, columnId }
setPresenceHighlights([
  { id: remote.userId, color: remote.color, rowId: remote.rowId, columnId: remote.columnId },
]);

The plugin resolves rowId to this grid's own current view row internally, so a locally-active sort or filter never mispaints it. A rowId that fell out of the current filter is dropped silently. Nothing paints for that highlight until the row is back in view.

The rowId-native RANGE form (RowIdRangePresenceHighlight) is the divergence-proof multi-cell selection: the plugin resolves every rowId and columnId against this grid's own view, drops rows that fell out of the current filter silently (and dev-warns once per entry when a columnId is hidden or unknown), and paints the surviving cells as one rect per contiguous run of resolved rows × runs of resolved columns. A local filter that splits the range paints one rect per fragment instead of one wrong rect:

// remote peer sends { userId, color, rowIds, columnIds } — no view-space coordinates involved
setPresenceHighlights([
  { id: remote.userId, color: remote.color, rowIds: remote.rowIds, columnIds: remote.columnIds },
]);

To branch on the form of a received entry, isRowIdPresenceHighlight(entry) and isRowIdRangePresenceHighlight(entry) narrow a PresenceHighlightEntry. Both come from the data-grid-presence entry point.

One entry paints at most 1024 fragments (MAX_RESOLVED_RECTS, the default of the maxRects option). Beyond the budget, the excess fragments are dropped and onExcessRects(entry, kept, total) fires once per entry, with a dev warning in dev:

const { plugin, ...rest } = useDataGridPresence({
  maxRects: 256,
  onExcessRects: (entry, kept, total) => console.info(`presence "${entry.id}": ${kept}/${total} fragments painted`),
});

PresenceHighlight's range form (view-index, the same coordinate system as GridSelection.current.range) works side by side with the rowId entries in the same array. It has one caveat: range is in the SENDER's view-index. A receiver with a different local sort or filter paints it on the wrong cells. A receiver-side live feed with reorder: "immediate" (see Streaming updates) moves rows in the same call. The range entry then points at the wrong rows on the receiver itself. Use the rowId-native forms whenever sender and receiver views can diverge, or when the receiver's own feed reorders rows while entries are active. The stable onSelectionChange payload (SelectionState) is rowId-native, so the rowId entries build straight from it. In development, the grid warns once when a row move, sort, filter, join operator change, or data replacement moves rows while a view-index entry is active.

The two entry shapes:

type PresenceHighlight = {
  id: string;
  color: string;
  range: GridRect; // view-space: indices into the sorted/filtered display order
  label?: string;
};

type RowIdPresenceHighlight = {
  id: string;
  color: string;
  rowId: string;
  columnId: string;
  label?: string;
};

Building the map yourself (e.g. to resolve a rowId outside the plugin) uses the same core hook, useDataGridRowIdToViewRow, which returns a ReadonlyMap<string, number> of the current view.

Rendering

  • Highlights reuse the core's overlay pipeline through the plugin ctx: they clamp to the window, split at pinned columns (pinned segments paint at the pinned cell's fixed screen position), and the color becomes the overlay node's --presence-color: border at full opacity, fill translucent enough that two overlapping highlights both stay legible.
  • Presence paints below the local active-cell ring and is fully aria-hidden and pointer-events-none: it never touches aria-selected and never reaches the row-level path. The label chip renders only while the range's top-left corner survives the window clamp.

Scope cuts

  • Data rows only. There is no presence painting on pinned row bands, since they already sit outside selection.
  • No cursors, no per-cell avatars, and no "who is online" chrome: presence is the in-window highlight overlay only. Any roster/avatar UI is plain app-level code the consumer builds themselves, not a grid feature.

Broadcasting your selection

Sending your own selection to peers (a real websocket sketch, the onSelectionChange wiring, and the view-index-vs-rowId payload trade-off) is on the Broadcasting your selection page.

On this page