Learn / State & data flow in React

State & data flow in React

There is no diagram state in React and no diagram logic in the binding — the headless instance owns the graph, and every hook is a subscription to it. Once you know which side owns what, every update path is one sentence long.

Uncontrolled or controlled — pick an owner

Uncontrolled (defaultNodes / defaultEdges): the props seed the instance once, and the instance owns the data from then on. Re-renders never snap the model back to the defaults — pass a new defaultNodes array later and nothing happens, by contract. Reach the graph imperatively (instance.getModel(), instance.setNodes()) when you need to.

Controlled (nodes + onNodesChange): your React state is the source of truth and the canvas mirrors it — in both directions.

Editor.tsx
import { GrafloriaFlow, useNodesState, useEdgesState } from '@grafloria/react';

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

  return (
    <div style={{ height: '100vh' }}>
      <GrafloriaFlow
        nodes={nodes} onNodesChange={onNodesChange}
        edges={edges} onEdgesChange={onEdgesChange} />
    </div>
  );
}

Choose uncontrolled when the diagram is self-contained — a viewer, a demo, an editor whose only consumer is itself. Choose controlled the moment anything outside the canvas needs the graph: persistence, an inspector panel, a server round-trip, derived UI. Mixing the two on one canvas is the classic trap — if you pass nodes, wire onNodesChange, or user edits will be overwritten by your stale state on the next render.

The triple, in depth

useNodesState returns [nodes, setNodes, onNodesChange], and the asymmetry between the second and third elements is the whole design. Two different types flow in the two directions:

const [nodes, setNodes, onNodesChange] = useNodesState(initial);
//     │        │         └─ (nodes: NodeModel[]) => void   ← the ENGINE's live models, in
//     │        └─ Dispatch<SetStateAction<NodeSpec[]>>     ← YOUR intent, out
//     └─ NodeSpec[]                                        ← plain data, owned by React

Outbound, you write specs: setNodes updates React state, the new array reaches the nodes prop, and the instance reconciles it into the live model. Inbound, the engine hands back models: the user drags or deletes, the instance emits nodes:change, the flow calls your onNodesChange with NodeModel[], and the hook converts them back to specs (toNodeSpec) and folds them into state. Without that return leg a controlled canvas would snap every dragged node back on the next render — the classic controlled-component trap, closed by construction.

The third element goes into onNodesChange — never the second. The tuple keeps the two directions apart on purpose: wiring setNodes where onNodesChange belongs is a type error, because one speaks NodeSpec[] and the other NodeModel[].

One timing detail worth knowing: nodes:change fires when nodes are added or removed and when a gesture commits — not per frame during a drag. The core moves the model and the DOM directly mid-gesture; your state is a mirror that catches up at the commit points. Alongside it sit the other event props — onSelectionChange, onConnect, onNodeClick, onEdgeClick — each a direct feed from the instance's event bus.

Reference diffing: what a new array means

The controlled props are compared by reference. Internally the flow runs instance.setNodes(nodes) in an effect keyed on the array itself — so a new reference triggers a reconcile of the live model (adds, removes, updates; never a remount), and the same reference is free. A fresh array literal on every render is therefore correct but wasteful: it reconciles on every unrelated render of the parent. Keep the arrays in state (the hooks do exactly this) or memoize them.

Two props deliberately break this rule: layout and plugins are compared by value (via their JSON), so inline object literals there never thrash — layout={{ name: 'dagre' }} re-runs only when the value actually changes, which is also why a declarative layout never fights a user's drag.

The instance is created once — inline callbacks are safe

createDiagram() runs in a mount effect, once. Every callback prop lives in a ref the component refreshes each render, so a new inline arrow — or a new nodes array, or a toggled fitView — never tears the instance down. This is load-bearing: recreating the instance on a prop change would throw away the camera, the selection and every mounted custom node. The unit test that pins this is blunt about it: re-render with new inline callbacks, assert onInit fired exactly once.

Reaching the instance: onInit vs useGrafloria

When the button lives in the same component as the flow, a ref via onInit is the shortest path:

import type { DiagramInstance } from '@grafloria/react';

const inst = useRef<DiagramInstance | null>(null);

<GrafloriaFlow defaultNodes={nodes} onInit={(i) => { inst.current = i; }} />
<button onClick={() => inst.current?.fitView()}>Fit</button>

When the consumer is a sibling of the canvas — a toolbar, a sidebar, an inspector — wrap both in <GrafloriaProvider> and call useGrafloria() from anywhere inside it. The hook returns the live DiagramInstance, or null until the flow has mounted:

App.tsx
import { GrafloriaProvider, GrafloriaFlow, useGrafloria } from '@grafloria/react';

function Toolbar() {
  const grafloria = useGrafloria();           // null until <GrafloriaFlow> mounts
  return (
    <button disabled={!grafloria} onClick={() => void grafloria?.getEngine().undo()}>
      Undo
    </button>
  );
}

export default function App() {
  return (
    <GrafloriaProvider>
      <Toolbar />
      <div style={{ height: '100vh' }}>
        <GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} />
      </div>
    </GrafloriaProvider>
  );
}

Children of <GrafloriaFlow> itself — overlays, panels passed as children — get useGrafloria() with no provider at all: the flow publishes its instance to its own store when none is above it. The provider is only for consumers outside the flow's subtree. Note where undo lives: instance.getEngine().undo() / .redo() — the engine, reached through the instance.

Live: drag, then undo from a button →

Subscription hooks

import { useSelection, useOnSelectionChange, useViewport } from '@grafloria/react';

const { nodes: selectedNodes, edges: selectedEdges } = useSelection();  // selection as state
useOnSelectionChange(({ nodes }) => setInspected(nodes[0] ?? null));    // selection as callback
const { zoom, x, y } = useViewport();                                   // the live camera

useSelection renders — use it for an inspector panel that shows the current selection. useOnSelectionChange fires — use it for side effects. Its handler is held in a ref, so the inline arrow everyone writes does not re-subscribe on every render; the test suite spies on instance.on to prove it. useViewport tracks viewport:change — a zoom badge or a hand-rolled minimap is a three-line component.

Save and load

For persistence, serialize the model, not your React state — the model is where the truth lives, including everything gestures changed:

persistence.tsx
import { DiagramSerializer } from '@grafloria/element';

// save — plain JSON out of the live model
const doc = new DiagramSerializer().serialize(instance.getModel());
localStorage.setItem('diagram', JSON.stringify(doc));

// load, option 1: full fidelity in one line (accepts the JSON string directly)
import { render, fromDocument } from '@grafloria/element';
render(fromDocument(saved), hostElement);

// load, option 2: the React way — a kit-spec host component
import { GrafloriaDiagram } from '@grafloria/react';
<GrafloriaDiagram spec={fromDocument(saved)} onReady={(instance) => { /* … */ }} />

fromDocument accepts the serializer's output, the portable envelope, or the JSON string of either, and returns a spec render() can mount — kit documents (dashboards included) come back with their interactive behavior rewired. For a lighter touch you can also deserialize and feed specs into an existing canvas with instance.setNodes(...) — the save-and-restore demo does exactly that, and also shows the deeper op-log pattern for collaborative documents.

Live: save, edit, restore →

SSR: correct before hydration

Every component in the binding carries 'use client', but <GrafloriaFlow> still renders on the server — all DOM work lives in effects, which never run there, so no typeof window checks and no dynamic() tricks are needed. To ship a correct page rather than an empty box, render the SVG on the server and hand it to the flow:

import { renderToStaticSVG } from '@grafloria/react';

// server:
const ssr = renderToStaticSVG({ nodes, width: 800, height: 600 });

// client:
<GrafloriaFlow nodes={nodes} ssr={ssr} />

The server markup is emitted verbatim (React does not diff inside dangerouslySetInnerHTML), and the mount effect adopts that DOM instead of rebuilding it — the hydration test asserts zero DOM nodes created and zero removed. No flash, no re-layout.

Custom nodes are absent server-side. They are framework components, so the server SVG contains the scene graph and an empty HTML layer; your components mount at hydration. Built-in nodes, edges, labels and geometry are all in the server markup.

For servers that only need an image of a diagram — reports, previews, caching — skip the component entirely: renderStatic from @grafloria/element is a pure spec-to-SVG function with zero DOM, and two renders of the same spec are byte-identical.

Live: deterministic headless render →

Where next