Skip to content

Multiplayer editing with Liveblocks

In this tutorial, you will turn Handsontable into a multiplayer grid with Liveblocks. You will learn how to sync cell values through Liveblocks Storage, share each user’s selected cell through Presence, and draw live selection borders for other users with a custom cell renderer.

Overview

Try the live example on Liveblocks

Multiplayer spreadsheet editing with Liveblocks: several users editing the same grid with colored selection cursors

This recipe connects Handsontable to Liveblocks, a hosted real-time collaboration service. Two kinds of state travel over the Liveblocks room. The grid’s cell values live in Storage (the shared document every user edits), and each user’s currently selected cell lives in Presence (ephemeral per-user state). A custom renderer reads the presence of other users and paints a colored border on the cells they have selected, so everyone sees where everyone else is working.

Liveblocks runs against a hosted backend that needs your own API key, so the grid cannot run inside this page. The code below is the complete client. Follow the steps to wire it into a Next.js app, or open the live example above.

Difficulty: Intermediate Time: ~20 minutes Stack: Next.js (App Router), React, Liveblocks, Handsontable, @handsontable/react-wrapper

What You’ll Build

A shared data grid where every connected user:

  • Edits the same cells in real time, so a change made by one user appears in every other user’s grid
  • Sees a colored border on the cell each other user has selected, with stacked borders when several users sit on the same cell
  • Joins through a single Liveblocks room with an initial 10×5 empty grid

Before you begin

This recipe assumes a Next.js app using the App Router. You also need a free Liveblocks account to get a public API key.

Install the dependencies:

Terminal window
npm install @liveblocks/client @liveblocks/react @handsontable/react-wrapper handsontable

Scaffold the Liveblocks configuration:

Terminal window
npx create-liveblocks-app@latest --init --framework react

Copy your public API key from the Liveblocks dashboard and use it in place of {{PUBLIC_KEY}} in Room.tsx (Step 2).

  1. Define Presence and Storage types

    TypeScript
    import type { LiveList } from "@liveblocks/client";
    export const GRID_ROWS = 10;
    export const GRID_COLS = 5;
    declare global {
    interface Liveblocks {
    Presence: {
    selectedCell: { row: number; col: number } | null;
    };
    Storage: {
    grid: LiveList<LiveList<string>>;
    };
    }
    }

    What’s happening: The global Liveblocks interface tells the Liveblocks hooks the exact shape of your room. Storage is the persisted, shared document: a LiveList of rows, each itself a LiveList of cell strings, so it can be mutated cell-by-cell without replacing the whole grid. Presence is each user’s ephemeral state: the row and column of their selected cell, or null when nothing is selected.

    Why a nested LiveList? A LiveList<LiveList<string>> lets two users edit different cells at the same time without conflict. Setting one cell calls row.set(colIndex, value) on only that inner list, so Liveblocks merges concurrent edits instead of replacing the entire grid.

  2. Set up the room

    TypeScript
    "use client";
    import { LiveList } from "@liveblocks/client";
    import { ReactNode } from "react";
    import {
    LiveblocksProvider,
    RoomProvider,
    ClientSideSuspense,
    } from "@liveblocks/react/suspense";
    import { GRID_COLS, GRID_ROWS } from "./liveblocks.config";
    export function Room({ children }: { children: ReactNode }) {
    return (
    <LiveblocksProvider publicApiKey={"{{PUBLIC_KEY}}"}>
    <RoomProvider
    id="my-room"
    initialPresence={{ selectedCell: null }}
    initialStorage={{
    grid: new LiveList(
    Array.from(
    { length: GRID_ROWS },
    () => new LiveList(Array.from({ length: GRID_COLS }, () => ""))
    )
    ),
    }}
    >
    <ClientSideSuspense fallback={<div>Loading…</div>}>
    {children}
    </ClientSideSuspense>
    </RoomProvider>
    </LiveblocksProvider>
    );
    }

    What’s happening: LiveblocksProvider connects the app to your Liveblocks project with the public API key. RoomProvider joins the room "my-room" and seeds it: initialPresence starts every user with no selected cell, and initialStorage builds a 10×5 grid of empty strings the first time the room is created. ClientSideSuspense holds back the children until the room’s storage has loaded.

  3. Wire up the page

    TypeScript
    import { Room } from "./Room";
    import { Table } from "./Table";
    export default function Page() {
    return (
    <Room>
    <Table />
    </Room>
    );
    }

    What’s happening: The page renders the Table inside the Room, so every Liveblocks hook the table uses resolves against the joined room.

  4. Read and write cell values through Storage

    JavaScript
    "use client";
    import { shallow } from "@liveblocks/client";
    import { HotTable } from "@handsontable/react-wrapper";
    import { registerAllModules } from "handsontable/registry";
    import { textRenderer } from "handsontable/renderers";
    import {
    useMutation,
    useOthersListener,
    useStorage,
    } from "@liveblocks/react/suspense";
    import { useCallback, useRef } from "react";
    import { GRID_COLS, GRID_ROWS } from "./liveblocks.config";
    registerAllModules();
    export function Table() {
    const hotRef = useRef(null);
    // Get the realtime grid contents from Liveblocks Storage
    const data = useStorage(
    (root) =>
    root.grid.map((row) =>
    Array.from({ length: GRID_COLS }, (_, c) => String(row[c] ?? ""))
    ),
    shallow
    );
    // Update a cell's value
    const updateCell = useMutation(({ storage }, rowIndex, colIndex, value) => {
    const grid = storage.get("grid");
    const row = grid.get(rowIndex);
    if (row) {
    row.set(colIndex, value);
    }
    }, []);
    // Update the grid when a cell is changed
    const afterChange = useCallback(
    (changes, source) => {
    if (!changes || source === "loadData") {
    return;
    }
    for (const [row, prop, , newVal] of changes) {
    if (typeof prop !== "number") {
    continue;
    }
    updateCell(
    row,
    prop,
    newVal === null || newVal === undefined ? "" : String(newVal)
    );
    }
    },
    [updateCell]
    );
    // Sync selected cell to presence
    const syncSelectedCellToPresence = useMutation(({ setMyPresence }, row, col) => {
    setMyPresence({
    selectedCell: { row: row < 0 ? 0 : row, col: col < 0 ? 0 : col },
    });
    }, []);
    const clearSelectedCellPresence = useMutation(({ setMyPresence }) => {
    setMyPresence({ selectedCell: null });
    }, []);
    // Render presence inside cells
    const renderDataCell = useMutation(({ others }, ...props) => {
    textRenderer(...props);
    const [, td, row, col] = props;
    // Find users who have selected this cell
    const selectedOthers = others.filter(
    (o) =>
    o.presence.selectedCell?.row === row &&
    o.presence.selectedCell?.col === col
    );
    if (!selectedOthers.length) {
    td.style.boxShadow = "";
    return;
    }
    // Add inner borders for selected users
    td.style.boxShadow = selectedOthers
    .map((p, i) => `inset 0 0 0 ${2 + i * 2}px ${p.info.color}`)
    .join(", ");
    }, []);
    // Re-render the table when others update their presence
    useOthersListener(({ type }) => {
    if (type === "update") {
    hotRef.current?.hotInstance?.render();
    }
    });
    return (
    <HotTable
    ref={hotRef}
    data={data}
    hotRenderer={renderDataCell}
    afterChange={afterChange}
    afterSelection={syncSelectedCellToPresence}
    afterDeselect={clearSelectedCellPresence}
    colHeaders={true}
    rowHeaders={true}
    height={400}
    width={600}
    licenseKey="non-commercial-and-evaluation"
    autoWrapRow={true}
    autoWrapCol={true}
    />
    );
    }

    The grid component above is the full client. The next steps walk through its three responsibilities, starting with reading and writing cell values:

    const data = useStorage(
    (root) =>
    root.grid.map((row) =>
    Array.from({ length: GRID_COLS }, (_, c) => String(row[c] ?? ""))
    ),
    shallow
    );
    const updateCell = useMutation(
    ({ storage }, rowIndex: number, colIndex: number, value: string) => {
    const grid = storage.get("grid");
    const row = grid.get(rowIndex);
    if (row) {
    row.set(colIndex, value);
    }
    },
    []
    );

    What’s happening: useStorage projects the LiveList grid into a plain string[][] that Handsontable accepts as its data, and re-runs whenever storage changes, so a remote edit flows straight into the grid. The shallow comparison stops needless re-renders when the projected array is structurally unchanged. useMutation returns updateCell, which writes a single cell back into storage; Liveblocks broadcasts that write to every other user.

    Why setDataAtCell is not used here: the grid’s data is driven entirely by useStorage. You never imperatively set cell values. You mutate storage, and the storage subscription re-feeds data, which keeps one source of truth.

  5. Sync edits and selection to the room

    const afterChange = useCallback(
    (changes: CellChange[] | null, source: ChangeSource) => {
    if (!changes || source === "loadData") {
    return;
    }
    for (const [row, prop, , newVal] of changes) {
    if (typeof prop !== "number") {
    continue;
    }
    updateCell(
    row,
    prop,
    newVal === null || newVal === undefined ? "" : String(newVal)
    );
    }
    },
    [updateCell]
    );
    const syncSelectedCellToPresence = useMutation(
    ({ setMyPresence }, row: number, col: number) => {
    setMyPresence({
    selectedCell: { row: row < 0 ? 0 : row, col: col < 0 ? 0 : col },
    });
    },
    []
    );
    const clearSelectedCellPresence = useMutation(({ setMyPresence }) => {
    setMyPresence({ selectedCell: null });
    }, []);

    What’s happening: afterChange runs on every local edit. It skips the initial loadData pass, then calls updateCell for each changed cell so the edit reaches storage. afterSelection is wired to syncSelectedCellToPresence, which writes the selected cell into the user’s presence; the < 0 ? 0 guards clamp Handsontable’s header coordinates (-1) to a valid cell. afterDeselect clears presence so the border disappears when the user clicks away.

  6. Render other users’ selections

    const renderDataCell = useMutation(
    ({ others }, ...props: Parameters<typeof textRenderer>) => {
    textRenderer(...props);
    const [, td, row, col] = props;
    const selectedOthers = others.filter(
    (o) =>
    o.presence.selectedCell?.row === row &&
    o.presence.selectedCell?.col === col
    );
    if (!selectedOthers.length) {
    td.style.boxShadow = "";
    return;
    }
    td.style.boxShadow = selectedOthers
    .map((p, i) => `inset 0 0 0 ${2 + i * 2}px ${p.info.color}`)
    .join(", ");
    },
    []
    );
    useOthersListener(({ type }) => {
    if (type === "update") {
    hotRef.current?.hotInstance?.render();
    }
    });

    What’s happening: renderDataCell is passed to Handsontable as hotRenderer. It first calls the built-in textRenderer to paint the value, then reads others (the presence of every other user in the room) and finds those whose selectedCell matches this cell. For each match it stacks an inset box-shadow in that user’s color, so two users on the same cell show two nested borders. useOthersListener re-renders the grid whenever another user’s presence changes, so borders follow them as they move.

    Why force a render? Handsontable does not know that remote presence changed. useOthersListener bridges that gap: on every presence update, it calls hotInstance.render() so the renderer re-runs with the new selection data.

How It Works - Complete Flow

  1. A user joins: RoomProvider connects to "my-room"; ClientSideSuspense waits for storage, then useStorage feeds the 10×5 grid into Handsontable.
  2. User A edits a cell: afterChange fires, updateCell writes the value into the LiveList grid in storage.
  3. Liveblocks broadcasts the storage change to everyone in the room.
  4. User B’s grid updates: their useStorage subscription re-runs, returning the new string[][], and Handsontable shows the edit, with no manual setDataAtCell call.
  5. User A selects a cell: afterSelection writes { row, col } into User A’s presence via setMyPresence.
  6. User B sees the selection: useOthersListener catches the presence update and re-renders; renderDataCell finds User A among others on that cell and draws a border in User A’s color.
  7. User A clicks away: afterDeselect sets presence back to null, and User B’s next render clears the border.

What you learned

  • How to model shared grid data as a nested Liveblocks LiveList so concurrent cell edits merge instead of overwriting each other.
  • How useStorage makes Liveblocks storage the single source of truth for Handsontable’s data, removing the need for imperative cell updates.
  • How useMutation writes both shared storage (cell values) and per-user presence (selection) back to the room.
  • How a custom hotRenderer reads the presence of others and paints stacked selection borders in each user’s color.
  • How useOthersListener re-renders the grid when remote presence changes so live cursors stay in sync.

Next steps

  • Add authentication so only permitted users can join a room, instead of using a public API key in the client.
  • Add comments anchored to cells with the Liveblocks comments recipe.
  • Show a live avatar stack of who is in the room using the same others presence data.
  • Compare with the WebSocket updates recipe to see a backend-agnostic real-time pattern that updates cells with setDataAtCell.