Skip to content
D
Documentation

React: state and subscriptions

how-to
5 min readUpdated

Use controlled state when application UI needs the graph; use subscriptions when a toolbar or inspector needs the live selection or camera. The React binding forwards your specs into live models and returns committed edits through callbacks—it does not implement diagram behavior in React.

This example renders two connected nodes, a sibling Fit button, a selection inspector, a camera readout, and node coordinates backed by React state.

Install the binding and its peers in your React project:

bash
npm install @grafloria/react @grafloria/engine @grafloria/renderer @grafloria/element react react-dom

1. Choose the state owner

GrafloriaFlow supports two ownership paths:

OwnershipPropsWhat you get
UncontrolleddefaultNodes, defaultEdgesThe props seed the instance at mount. Later default arrays do not update it; use instance methods for subsequent changes.
Controllednodes with onNodesChange, edges with onEdgesChangeReact owns plain specs, and the binding reconciles new arrays into the live model. Callbacks return model changes to React.

Choose uncontrolled for a self-contained viewer or editor. Choose controlled when your application needs graph-derived UI or a server round-trip. For persistence, save the live document rather than the React projection; see Save and restore documents.

useNodesState returns [nodes, setNodes, onNodesChange]. useEdgesState returns the equivalent edge tuple:

  • The first element contains plain NodeSpec or EdgeSpec values to pass to the component.
  • The second element is a React state setter: use it to write application intent as specs.
  • The third element accepts the engine's live models and converts them back to specs. Pass it to the matching change callback to close the return leg.

For the controlled-component pitfall, see the React quick start. The example below wires both return legs.

2. Share the instance with siblings and subscribe

Wrap the canvas and its sibling consumers in GrafloriaProvider. useGrafloria returns the live DiagramInstance, or null before the canvas mounts. Disable instance-dependent controls while it is null.

Use useSelection for rendered selection state and useViewport for { zoom, x, y }. If a consumer needs a callback rather than the current state, use useOnSelectionChange. Its inline handler stays current through a ref without resubscribing on each render. A consumer colocated with the canvas can instead use its onSelectionChange callback prop.

Replace your application's App.tsx with this component:

tsx
import { useState } from 'react';
import {
  GrafloriaFlow,
  GrafloriaProvider,
  useEdgesState,
  useGrafloria,
  useNodesState,
  useOnSelectionChange,
  useSelection,
  useViewport,
} from '@grafloria/react';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';

const initialNodes: NodeSpec[] = [
  {
    id: 'extract', label: 'Extract',
    position: { x: 80, y: 100 }, size: { width: 150, height: 66 },
  },
  {
    id: 'load', label: 'Load',
    position: { x: 330, y: 180 }, size: { width: 150, height: 66 },
  },
];
const initialEdges: EdgeSpec[] = [
  { id: 'pipeline', source: 'extract', target: 'load' },
];

function Inspector() {
  const instance = useGrafloria();
  const selection = useSelection();
  const { zoom, x, y } = useViewport();
  const [selectionEvents, setSelectionEvents] = useState(0);

  useOnSelectionChange(() => setSelectionEvents((count) => count + 1));

  return (
    <aside>
      <button disabled={!instance} onClick={() => instance?.fitView()}>
        Fit
      </button>
      <p>Selected: {selection.nodes.length} nodes, {selection.edges.length} edges</p>
      <p>Node ids: {selection.nodes.map((node) => node.id).join(', ') || 'none'}</p>
      <p>Zoom: {zoom.toFixed(2)}; origin: {x.toFixed(1)}, {y.toFixed(1)}</p>
      <p>Selection events: {selectionEvents}</p>
    </aside>
  );
}

function Editor() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, , onEdgesChange] = useEdgesState(initialEdges);

  return (
    <>
      <button onClick={() => setNodes((current) => current.map((node) =>
        node.id === 'load' ? { ...node, label: 'Load records' } : node
      ))}>
        Rename Load
      </button>
      <ul>
        {nodes.map((node) => (
          <li key={node.id}>
            {node.label}: {node.position?.x.toFixed(1)}, {node.position?.y.toFixed(1)}
          </li>
        ))}
      </ul>
      <div style={{ height: 400 }}>
        <GrafloriaFlow
          nodes={nodes} onNodesChange={onNodesChange}
          edges={edges} onEdgesChange={onEdgesChange}
        />
      </div>
    </>
  );
}

export default function App() {
  return (
    <GrafloriaProvider>
      <Inspector />
      <Editor />
    </GrafloriaProvider>
  );
}
Extract connects to Load beneath the coordinate list. Look at the sibling Fit button, Rename Load button, and selection and camera readouts above the canvas.

Click a node to update the selection inspector and event counter. Drag a node and release it: the coordinate list catches up through onNodesChange. Click Rename Load to send a new spec array into the canvas; the second node's label becomes “Load records”. Click Fit to frame the content through the same instance the canvas uses; the camera readout follows viewport changes.

The return path reports document changes at commit points, not every frame of a drag. During the gesture, the core moves the model and paints the canvas directly. Keep the controlled arrays in state or memoize them: their reference is the effect dependency. A new array triggers reconciliation, not a remount; nodes with matching ids keep their live objects.

If the consumer is in the same component as the canvas, capture the instance with onInit in a React ref instead. Consumers passed as GrafloriaFlow children already have access to the flow's own store and need no outer provider. Siblings do need the shared provider shown above.

The binding refreshes callback refs on each render and keeps the instance for the mounted canvas. It unsubscribes and disposes the instance on unmount; the subscription hooks also return their unsubscribe functions from effects.

3. Adopt server markup at client mount

For an SSR application, call renderToStaticSVG on the server. It returns the layer html, standalone svg, stylesheet css, and hydration snapshot. Pass the result to ssr with the same node and edge specs, and include its stylesheet so the diagram is styled before client JavaScript runs.

Here is a Next.js App Router example. The server page passes a serializable StaticRenderResult into a client component; the client mounts the flow and exposes a Fit button through the provider.

tsx
import { renderToStaticSVG } from '@grafloria/renderer';
import type { EdgeSpec, NodeSpec } from '@grafloria/renderer';
import HydratedFlow from './HydratedFlow';

export default function Page() {
  const nodes: NodeSpec[] = [
    {
      id: 'extract', label: 'Extract',
      position: { x: 80, y: 100 }, size: { width: 150, height: 66 },
    },
    {
      id: 'load', label: 'Load',
      position: { x: 330, y: 180 }, size: { width: 150, height: 66 },
    },
  ];
  const edges: EdgeSpec[] = [
    { id: 'pipeline', source: 'extract', target: 'load' },
  ];
  const ssr = renderToStaticSVG({
    nodes, edges, width: 800, height: 400, instanceId: 'pipeline-ssr',
  });

  return (
    <>
      <style>{ssr.css}</style>
      <HydratedFlow nodes={nodes} edges={edges} ssr={ssr} />
    </>
  );
}
tsx
'use client';

import { GrafloriaFlow, GrafloriaProvider, useGrafloria } from '@grafloria/react';
import type { EdgeSpec, NodeSpec, StaticRenderResult } from '@grafloria/renderer';

function FitButton() {
  const instance = useGrafloria();
  return (
    <button disabled={!instance} onClick={() => instance?.fitView()}>Fit</button>
  );
}

export default function HydratedFlow({ nodes, edges, ssr }: {
  nodes: NodeSpec[];
  edges: EdgeSpec[];
  ssr: StaticRenderResult;
}) {
  return (
    <GrafloriaProvider>
      <FitButton />
      <div style={{ width: 800, maxWidth: '100%', height: 400 }}>
        <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} ssr={ssr} />
      </div>
    </GrafloriaProvider>
  );
}

The server response contains the two nodes and their connecting edge. At client mount, the flow creates its live instance using the snapshot and adopts the existing diagram DOM instead of rebuilding it. After mount, you can select and drag nodes and use Fit. This version uses uncontrolled defaults: client-side edits belong to the instance.

The flow freezes the initial SSR markup for the component's lifetime. Later ssr values do not replace the DOM the instance owns. Custom HTML nodes are absent from the server render and mount on the client; built-in nodes, ports, edges, labels, and routing are present in the SVG.

Options that matter

The ownership and hydration props belong to GrafloriaFlowProps. The callback types use NodeModel and LinkModel, not spec arrays. The HydrationSnapshot carries the instance scope, dimensions, zoom, and camera origin.

OptionTypeDefaultWhat it does
nodes / edgesNodeSpec[] / EdgeSpec[]OmittedControls the corresponding data; new array references reconcile into the instance.
defaultNodes / defaultEdgesNodeSpec[] / EdgeSpec[]Empty arrays when no controlled inputs existSeeds the initial model only.
onNodesChange / onEdgesChange(nodes: NodeModel[]) => void / (edges: LinkModel[]) => voidOmittedReceives live models; the state hooks supply the model-to-spec return handlers.
onInit(instance: DiagramInstance) => voidOmittedReceives the instance after the flow publishes it to its store.
ssr{ html: string; snapshot: HydrationSnapshot }OmittedEmits initial server markup and supplies the snapshot for DOM adoption.

Give each server-rendered diagram on a page a distinct instanceId.

Was this page helpful?