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.
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.
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:
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:
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.
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.
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
- Custom nodes in React — the portal mechanism and the NodeProps contract.
- Dashboards in React — data-first boards with their own handle-based state story.
- Commands and undo — why every gesture is already undoable.
- Collaboration — the
collabprop joins a CRDT session over any transport.