Sign in

API Reference - @liveblocks/react-flow

@liveblocks/react-flow provides you with React hooks and components that add collaboration to any React Flow diagram. It adds multiplayer data syncing, document persistence on the cloud, and realtime cursors.

Read our get started guide to learn more.

Setup

If you’re not already using React Flow, follow their guide to get started. Install it and include its base styles.

Terminal
npm install @xyflow/react
import "@xyflow/react/dist/style.css";

Install Liveblocks’ packages:

Terminal
npm install @liveblocks/client @liveblocks/react @liveblocks/react-ui @liveblocks/react-flow

Import and use the useLiveblocksFlow hook to make React Flow collaborative:

"use client";
import { ReactFlow } from "@xyflow/react";import { RoomProvider } from "@liveblocks/react";import { useLiveblocksFlow } from "@liveblocks/react-flow";import "@xyflow/react/dist/style.css";
function Flow() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete, isLoading, } = useLiveblocksFlow();
if (isLoading) { return <div>Loading…</div>; }
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} /> );}
export function App() { return ( <RoomProvider id="my-room-id"> <Flow /> </RoomProvider> );}

Then, import and add the Cursors component (alongside Liveblocks’ styles) to add realtime cursors inside React Flow’s canvas:

"use client";
import { ReactFlow } from "@xyflow/react";import { RoomProvider } from "@liveblocks/react";import { useLiveblocksFlow, Cursors } from "@liveblocks/react-flow";import "@xyflow/react/dist/style.css";import "@liveblocks/react-ui/styles.css";import "@liveblocks/react-flow/styles.css";
function Flow() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete, isLoading, } = useLiveblocksFlow();
if (isLoading) { return <div>Loading…</div>; }
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} > <Cursors /> </ReactFlow> );}
export function App() { return ( <RoomProvider id="my-room-id"> <Flow /> </RoomProvider> );}

useLiveblocksFlow

This hook returns a controlled React Flow state made collaborative using Liveblocks Storage.

You can pass initial nodes and edges to the hook which will be set when entering the room for the first time.

"use client";
import { ReactFlow } from "@xyflow/react";import { RoomProvider } from "@liveblocks/react";import { useLiveblocksFlow } from "@liveblocks/react-flow";import "@xyflow/react/dist/style.css";
function Flow() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete, isLoading, } = useLiveblocksFlow({ nodes: { initial: [ { id: "1", type: "input", data: { label: "Node 1" }, position: { x: 250, y: 25 }, }, { id: "2", data: { label: "Node 2" }, position: { x: 100, y: 125 }, }, ], // sync: { "*": { label: false } }, }, edges: { initial: [{ id: "e1-2", source: "1", target: "2" }], // sync: { "*": { ... } }, }, });
if (isLoading) { return <div>Loading…</div>; }
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} /> );}
export function App() { return ( <RoomProvider id="my-room-id"> <Flow /> </RoomProvider> );}
Options
  • nodes.initialNode[]

    Default nodes used when the room has no data yet.

  • nodes.syncNodeSyncConfig

    Per-type sync configuration for node data keys. See Sync config.

  • edges.initialEdge[]

    Default edges used when the room has no data yet.

  • edges.syncEdgeSyncConfig

    Per-type sync configuration for edge data keys. See Sync config.

  • storageKeystring

    The key used to store the flow in Liveblocks Storage. Defaults to "flow". See storageKey.

  • suspenseboolean

    When true, suspends until the diagram is ready. Learn more about this in the Suspense section.

Options are not reactive

The options passed to the hook (initial nodes, edges, storage key, Suspense, etc.) are read once when the hook mounts. Later changes to those options will not take effect.

Returns
  • nodesNode[] | null

    Current nodes, null while loading unless using Suspense, in which case it is always an array.

  • edgesEdge[] | null

    Current edges, null while loading unless using Suspense, in which case it is always an array.

  • isLoadingboolean

    Whether the diagram is still loading. When using Suspense, always false after the hook has resumed.

  • onNodesChangeOnNodesChange

    Pass to React Flow’s onNodesChange.

  • onEdgesChangeOnEdgesChange

    Pass to React Flow’s onEdgesChange.

  • onConnectOnConnect

    Pass to React Flow’s onConnect. Handles new edges.

  • onDeleteOnDelete

    Pass to React Flow’s onDelete. Handles node and edge deletions atomically so that deleting a node and its related edges count as a single undoable action.

Local state vs Storage

Some React Flow fields are intentionally not written to Liveblocks Storage so each client keeps their own selection and interaction state:

  • Nodes: selected, dragging, measured, resizing
  • Edges: selected

Everything else on nodes and edges (including position, width and height, data, handles, and edge endpoints) is synchronized through Storage. If you want specific keys inside node.data or edge.data to stay local-only too, use the sync config.

Undo / Redo

Undo and redo are automatically enabled for the entire flow state. All synced changes to nodes and edges are recorded on the undo stack, including position changes, data updates, additions, and removals.

A few things are handled automatically:

  • Dragging and resizing produce many live updates during a drag, but produce only a single action on the undo stack
  • Deleting nodes and edges in a single action will undo together
  • Local-only properties are not recorded on the undo stack

To wire up undo/redo in your UI, just use Liveblocks’ normal useHistory hook:

import { useHistory } from "@liveblocks/react";
function Toolbar() { const history = useHistory(); return ( <> <button onClick={history.undo} disabled={!history.canUndo()}> Undo </button> <button onClick={history.redo} disabled={!history.canRedo()}> Redo </button> </> );}

Custom nodes

Custom nodes work like in any React Flow setup.

"use client";
import { useLiveblocksFlow } from "@liveblocks/react-flow";import type { Node, NodeProps } from "@xyflow/react";import { ReactFlow, useReactFlow } from "@xyflow/react";import { memo, useCallback } from "react";
type TaskNode = Node<{ title: string }, "task">;
const TaskNode = memo(({ id, data }: NodeProps<TaskNode>) => { const { updateNode } = useReactFlow();
const rename = useCallback(() => { updateNode(id, (node) => ({ ...node, data: { ...node.data, title: "Updated" }, })); }, [id, updateNode]);
return ( <div> <button type="button" onClick={rename}> {data.title} </button> </div> );});
function Flow() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete } = useLiveblocksFlow<TaskNode>({ suspense: true, nodes: { initial: [ { id: "1", type: "task", position: { x: 0, y: 0 }, data: { title: "Shared task" }, }, ], }, });
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} nodeTypes={{ task: TaskNode }} /> );}

Suspense

By default, useLiveblocksFlow returns isLoading: true, nodes: null, and edges: null while loading. You can use the suspense option to suspend until the diagram is ready, when doing so, nodes and edges will always be arrays and isLoading will always be false.

"use client";
import { ReactFlow } from "@xyflow/react";import { RoomProvider, ClientSideSuspense } from "@liveblocks/react";import { useLiveblocksFlow } from "@liveblocks/react-flow";import "@xyflow/react/dist/style.css";
function Flow() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete, } = useLiveblocksFlow({ suspense: true });
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} /> );}
export function App() { return ( <RoomProvider id="my-room-id"> <ClientSideSuspense fallback={<div>Loading…</div>}> <Flow /> </ClientSideSuspense> </RoomProvider> );}

Storage key

By default, useLiveblocksFlow stores nodes and edges under key "flow" in Liveblocks Storage. Use the storageKey option to choose a different key or to support multiple diagrams in a single room.

"use client";
import { ReactFlow } from "@xyflow/react";import { useLiveblocksFlow } from "@liveblocks/react-flow";
function FlowA() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete } = useLiveblocksFlow({ suspense: true, storageKey: "flowA" });
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} /> );}
function FlowB() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete } = useLiveblocksFlow({ suspense: true, storageKey: "flowB" });
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} /> );}

Sync config for node.data

By default, every key inside a node or edge’s data object is getting deeply synced. Internally objects are stored as LiveObjects, arrays as LiveLists, etc, to enable fine-grained conflict-free merging automatically. If two users update different properties on the same node or edge, their changes will get merged without conflicts.

For some data, this default behavior is not desirable.

Each key in the config accepts a sync mode:

ModeBehavior
trueDeeply sync and allow conflict-free merging (default).
falseKeep value local-only. Not synced to other clients at all. Other clients will see undefined.
"atomic"Synced, but replaced as a whole (last-writer-wins). No automatic conflict resolution.
{ ... }Nested config. Applies recursively to sub-keys of the value.

Use "*" as a fallback for all node (or edge) types.

const { ... } = useLiveblocksFlow({  nodes: {    sync: {      // Applies to all node types      "*": {        label: false,       // Don’t sync node.data.label        color: "atomic",    // Sync as a single value, replaced as-a-whole      },
// Additional overrides for specific node types myCustomNode: { showPreview: false, // Don’t sync myCustomNode.data.showPreview }, }, }, edges: { sync: { "*": { hovered: false, // Don’t sync edge.data.hovered style: "atomic", // Sync as a single value, replaced as-a-whole }, }, },});

Cursors

Add the Cursors component inside your ReactFlow component to add realtime cursors inside React Flow’s canvas. Also import Liveblocks’ styles when using it.

"use client";
import { RoomProvider } from "@liveblocks/react";import { useLiveblocksFlow, Cursors } from "@liveblocks/react-flow";import "@xyflow/react/dist/style.css";import "@liveblocks/react-ui/styles.css";import "@liveblocks/react-flow/styles.css";
function Flow() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete, isLoading, } = useLiveblocksFlow();
if (isLoading) { return <div>Loading…</div>; }
return ( <ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} > <Cursors /> </ReactFlow> );}

It works similarly to @liveblocks/react-ui’s Cursors component.

By default, cursor coordinates are stored in Presence under "cursor". Use presenceKey to support multiple diagrams in a single room.

User information

Cursors uses resolveUsers to resolve each user’s information and then uses the name and color properties.

<LiveblocksProvider  authEndpoint="/api/liveblocks-auth"  resolveUsers={async ({ userIds }) => {    // ["stacy@example.com", ...]    console.log(userIds);
// Get users from your back-end const users = await (userIds);
// [{ name: "Stacy", color: "#22c55e"}, ...] console.log(users);
// Return a list of users return users; }}> <RoomProvider id="my-room-id">{/* ... */}</RoomProvider></LiveblocksProvider>

Customize cursors

Pass a Cursor component through the components prop to control how each cursor is rendered. It receives userId and connectionId via its props. Its position and visibility are still handled by Cursors.

"use client";
import { Cursors } from "@liveblocks/react-flow";import { Cursor, type CursorsCursorProps } from "@liveblocks/react-ui";import { useUser } from "@liveblocks/react";
function MyCursor({ userId }: CursorsCursorProps) { const { user, isLoading } = useUser(userId);
if (isLoading) { return null; }
return ( <Cursor label={ user ? ( <> {user.countryFlag} {user.name} </> ) : undefined } color={user?.color} /> );}
function Flow() { return ( <ReactFlow> <Cursors components={{ Cursor: MyCursor }} /> </ReactFlow> );}

Props

  • presenceKeystringDefault is "cursor"

    The key used to store cursor coordinates in users’ Presence.

  • componentsPartial<CursorsComponents>

    Override the component’s components.

Updating your Flow from the server

To update a React Flow from your Node.js back-end, use mutateFlow.

import { Liveblocks } from "@liveblocks/node";import { mutateFlow } from "@liveblocks/react-flow/node";//                                                 ~~~~
const client = new Liveblocks({ secret: "sk_prod_xxxxxxxxxxxxxxxxxxxxxxxx",});
await mutateFlow( { client, roomId: "my-room-id" },
(flow) => { flow.addNode({ id: "1", position: { x: 0, y: 0 }, data: { label: "Hello" }, }); flow.addNode({ id: "2", position: { x: 100, y: 100 }, data: { label: "World" }, }); flow.addEdge({ id: "e1-2", source: "1", target: "2" }); });
Terminal
npm install @liveblocks/node @liveblocks/react-flow

mutateFlow

mutateFlow opens a room’s flow for reading and mutating. A "flow" is a collection of nodes and edges stored in Liveblocks Storage. By default, your flow gets persisted in the Room’s Storage under the top-level key "flow", but you can change this with the storageKey option.

No need to work with Liveblocks primitives

Even though internally mutateFlow uses Liveblocks Storage and its primitives like LiveObjects, LiveLists, and LiveMaps, you don’t need to work with those directly. You just work with React Flow nodes and edges as you normally would, and they get intelligently stored and synchronized via Live structures in the most efficient manner.

Options
  • clientLiveblocksRequired
  • roomIdstringRequired

    The ID of the room whose flow to mutate.

  • storageKeystring

    The key used to store the flow in Liveblocks Storage. Defaults to "flow". Must match the storageKey used on the client.

  • nodes.syncNodeSyncConfig

    Per-type sync configuration for node data keys. Must match the sync config used on the client.

  • edges.syncEdgeSyncConfig

    Per-type sync configuration for edge data keys. Must match the sync config used on the client.

Custom node and edge types

Pass your custom Node and Edge types as type parameters to get full type safety.

import type { Node, Edge } from "@xyflow/react";import { mutateFlow } from "@liveblocks/react-flow/node";
type TaskNode = Node<{ title: string; priority: number }, "task">;type CustomEdge = Edge<{ weight: number }, "weighted">;
await mutateFlow<TaskNode, CustomEdge>( { client: liveblocks, roomId: "my-room-id" }, (flow) => { // flow.addNode, flow.updateNodeData, etc. are fully typed flow.addNode({ id: "t1", type: "task", position: { x: 0, y: 0 }, data: { title: "Ship it", priority: 1 }, }); });

MutableFlow API

The callback receives a MutableFlow instance. All reads reflect mutations made earlier in the same callback.

flow.nodes

Returns the current list of nodes as a readonly array.

await mutateFlow({ client, roomId }, (flow) => {  const allNodes = flow.nodes; // readonly Node[]});

flow.edges

Returns the current list of edges as a readonly array.

await mutateFlow({ client, roomId }, (flow) => {  const allEdges = flow.edges; // readonly Edge[]});

flow.toJSON

Returns a plain object with nodes and edges arrays.

await mutateFlow({ client, roomId }, (flow) => {  const snapshot = flow.toJSON();  // { nodes: [...], edges: [...] }});

flow.getNode

Returns a single node by ID, or undefined if not found.

await mutateFlow({ client, roomId }, (flow) => {  const node = flow.getNode("n1");});

flow.getEdge

Returns a single edge by ID, or undefined if not found.

await mutateFlow({ client, roomId }, (flow) => {  const edge = flow.getEdge("e1");});

flow.addNode / flow.addNodes

Adds one or more nodes. If a node with the same ID already exists, it is replaced.

await mutateFlow({ client, roomId }, (flow) => {  flow.addNode({    id: "1",    position: { x: 0, y: 0 },    data: { label: "A" },  });
flow.addNodes([ { id: "2", position: { x: 100, y: 0 }, data: { label: "B" } }, { id: "3", position: { x: 200, y: 0 }, data: { label: "C" } }, ]);});

flow.updateNode

Updates a node by ID. Accepts a partial object to merge, or an updater function. No-op if the node does not exist.

When using an updater function, always return a new object. Never mutate the provided value in-place.

await mutateFlow({ client, roomId }, (flow) => {  // With a partial object  flow.updateNode("1", { position: { x: 100, y: 200 } });
// With an updater function flow.updateNode("1", (node) => ({ ...node, position: { x: node.position.x + 10, y: node.position.y }, }));});

flow.updateNodeData

Updates only a node’s data by ID. Accepts a partial object to merge, or an updater function. No-op if the node does not exist.

When using an updater function, always return a new object. Never mutate the provided value in-place.

await mutateFlow({ client, roomId }, (flow) => {  // Merge partial data  flow.updateNodeData("1", { label: "Updated" });
// With an updater function flow.updateNodeData("1", (data) => ({ ...data, color: "red", }));});

flow.removeNode / flow.removeNodes

Removes one or more nodes by ID.

await mutateFlow({ client, roomId }, (flow) => {  flow.removeNode("1");  flow.removeNodes(["2", "3"]);});

flow.addEdge / flow.addEdges

Adds one or more edges. If an edge with the same ID already exists, it is replaced.

await mutateFlow({ client, roomId }, (flow) => {  flow.addEdge({ id: "e1-2", source: "1", target: "2" });
flow.addEdges([ { id: "e2-3", source: "2", target: "3" }, { id: "e3-4", source: "3", target: "4" }, ]);});

flow.updateEdge

Updates an edge by ID. Accepts a partial object to merge, or an updater function. No-op if the edge does not exist.

When using an updater function, always return a new object. Never mutate the provided value in-place.

await mutateFlow({ client, roomId }, (flow) => {  // With a partial object  flow.updateEdge("e1-2", { label: "My edge" });
// With an updater function flow.updateEdge("e1-2", (edge) => ({ ...edge, label: "Updated", }));});

flow.updateEdgeData

Updates only an edge’s data by ID. Accepts a partial object to merge, or an updater function. No-op if the edge does not exist.

When using an updater function, always return a new object. Never mutate the provided value in-place.

await mutateFlow({ client, roomId }, (flow) => {  // Merge partial data  flow.updateEdgeData("e1-2", { weight: 5 });
// With an updater function flow.updateEdgeData("e1-2", (data) => ({ ...data, color: "red", }));});

flow.removeEdge / flow.removeEdges

Removes one or more edges by ID.

await mutateFlow({ client, roomId }, (flow) => {  flow.removeEdge("e1-2");  flow.removeEdges(["e2-3", "e3-4"]);});

How mutations are flushed

The mutateFlow function is built on top of Liveblocks.mutateStorage, but rather than giving you access to the Liveblocks Storage root directly, it gives you a convenient API tailored to React Flow—a MutableFlow instance you can use to read or mutate your nodes and edges directly.

Under the hood, everything still gets stored and synced efficiently via Live structures (LiveObject, LiveMap, etc), but this is an implementation detail.

Just like with Liveblocks.mutateStorage, your mutation callback can be long-running. During its execution, any mutations you make will periodically get flushed to the server in the background, and when your callback finishes, the final state will be flushed before the promise returned by mutateFlow resolves.