Learn / React in 10 minutes

React in 10 minutes

From npm install to a flow editor with custom nodes, connection rules, undo and export. Every snippet below is real, current API — the same code the React demo gallery runs.

1 — Install and mount a canvas

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

One component does the heavy lifting: <GrafloriaFlow>. Give it nodes, edges, and — this matters — a parent with a real height. The canvas fills 100% of its container, and 100% of zero is an empty page.

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

const nodes = [
  { id: 'a', position: { x: 60, y: 80 },  size: { width: 180, height: 80 }, data: { label: 'Ingest' } },
  { id: 'b', position: { x: 380, y: 80 }, size: { width: 180, height: 80 }, data: { label: 'Publish' } },
];
const edges = [{ id: 'e1', source: 'a', target: 'b' }];

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

That's already a working editor: drag nodes, draw connections between them, pan and zoom, and — because of plugins — a minimap, zoom controls and a dotted background (all lazy-loaded, they cost nothing if you omit the prop). defaultNodes/defaultEdges is the uncontrolled form: the canvas owns the data from here on. No stylesheet import is needed — the renderer injects its own scoped styles.

2 — Controlled state, the React way

When your app needs to own the graph, use useNodesState / useEdgesState. Each returns a triple — and the third element is the change handler for the canvas, not the second:

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' }}>
      <button onClick={() => setNodes((all) => [...all,
        { id: 'n' + all.length, position: { x: 80, y: 80 }, data: { label: 'New' } }])}>
        Add node
      </button>
      <GrafloriaFlow
        nodes={nodes} onNodesChange={onNodesChange}
        edges={edges} onEdgesChange={onEdgesChange} />
    </div>
  );
}
Why the third element? setNodes updates your state with specs; onNodesChange receives the engine's live NodeModel[] after a drag or delete and folds them back into your state. Wiring setNodes into onNodesChange type-errors — the tuple keeps the two directions separate on purpose.

3 — Custom nodes are just components

Map a node type to a component with nodeTypes, and mark the spec custom: true so it renders through the HTML layer instead of as SVG. The node still drags, routes and hit-tests like any other:

Card.tsx
function Card({ data, selected }) {
  return (
    <div style={{ height: '100%', background: '#fff', borderRadius: 12,
                  border: selected ? '2px solid #3B52D9' : '1.5px solid #94A5F0',
                  padding: '10px 14px', boxSizing: 'border-box' }}>
      <div style={{ fontWeight: 700 }}>{data.title}</div>
      <div style={{ fontSize: 12, color: '#5A6478' }}>owner: {data.owner}</div>
    </div>
  );
}

const nodes = [
  { id: 'a', type: 'card', custom: true, position: { x: 80, y: 90 },
    size: { width: 230, height: 110 }, data: { title: 'Build', owner: 'CI' } },
  { id: 'b', type: 'card', custom: true, position: { x: 430, y: 90 },
    size: { width: 230, height: 110 }, data: { title: 'Deploy', owner: 'CD' } },
];

<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} nodeTypes={{ card: Card }} />

Your component receives { id, data, selected, node }node is the live engine model, the escape hatch when you need more than data.

Live: custom nodes in React →

4 — Ports and connection rules

Ports are part of the node spec — no separate handle components. Declare them with a side, a direction, and optionally a dataType:

const nodes = [
  { id: 'src', position: { x: 120, y: 260 }, size: { width: 130, height: 70 }, label: 'number src',
    ports: [{ id: 'out', side: 'right', type: 'output', dataType: 'number' }] },
  { id: 'num', position: { x: 640, y: 140 }, size: { width: 130, height: 70 }, label: 'number in',
    ports: [{ id: 'nin', side: 'left', type: 'input', dataType: 'number' }] },
  { id: 'str', position: { x: 640, y: 400 }, size: { width: 130, height: 70 }, label: 'string in',
    ports: [{ id: 'sin', side: 'left', type: 'input', dataType: 'string' }] },
];

Two validation mechanisms compose. Type compatibility is declarative — register the types once and incompatible ports refuse the wire mid-drag:

import { portTypeRegistry } from '@grafloria/element';

portTypeRegistry.registerAll([
  { name: 'number', color: '#2563eb', compatibleWith: ['number'] },
  { name: 'string', color: '#9333ea', compatibleWith: ['string'] },
]);

Custom rules are a validator function — return true to allow, or a string to veto with a reason:

import { registerConnectionValidator, clearConnectionValidators } from '@grafloria/element';

useEffect(() => {
  const dispose = registerConnectionValidator(({ sourcePort, targetPort }) => {
    if (sourcePort?.type === 'output' && targetPort?.type === 'output')
      return 'an output cannot feed another output';
    return true;
  });
  return () => { dispose(); clearConnectionValidators(); };
}, []);
Validators are global, not per-canvas — always dispose in the effect cleanup or they leak across remounts (StrictMode double-invoke included). Ports are invisible until hover by default; show them permanently with interaction={{ portVisibility: 'always' }} on the flow.

Live: typed ports →

Live: connection validation →

5 — Undo comes free; buttons reach the engine

⌘Z/Ctrl+Z and ⌘⇧Z/Ctrl+Y work with zero wiring — every gesture is a command on the engine's history. For your own buttons, reach the engine through the instance:

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

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

<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges}
  onInit={(instance) => { inst.current = instance; }} />

<button onClick={() => void inst.current?.getEngine().undo()}>Undo</button>
<button onClick={() => void inst.current?.getEngine().redo()}>Redo</button>

Live: drag, then ⌘Z →

6 — Auto-layout

Stop hand-placing nodes: name a layout and the engine arranges them. ELK is the heavyweight — it loads lazily (a separate ~432 KB gz chunk) only when first used:

<GrafloriaFlow defaultNodes={nodes} defaultEdges={edges} layout="elk" />

// or configured:
<GrafloriaFlow layout={{ name: 'dagre', options: { direction: 'TB', rankSpacing: 80 } }} ... />

Registered names: auto, elk, dagre, layered, tree, grid, circular, radial, force, spectral, community. The prop re-runs on value change only — it will never fight a user's drag. To re-layout on demand, call instance.getEngine().layout('layered', { direction: 'LR' }).

Live: ELK layout →

7 — Save, load, export

// PNG (data: URL) and SVG (raw string) — exports the scene graph, not a screenshot
const png = await inst.current.export('png', { scale: 2 });
const svg = await inst.current.export('svg');
const pdf = await inst.current.export('pdf');   // real vector PDF

// Mermaid-compatible text round-trip
const text = inst.current.exportText();          // valid Mermaid + position sidecar
inst.current.loadText(text);                     // reconciles into the live canvas

Pass { embedModel: true } to PNG/SVG export and the diagram model rides inside the file — the exported image re-opens as an editable diagram.

Live: PNG/SVG download →

Where next

  • Every demo as a React component — 100+ routes, source shown.
  • The model — what nodes, ports, links and groups really are.
  • Collaboration — the collab prop and CRDT sync.
  • Next.js: all components carry 'use client'; for server rendering use the ssr prop with renderToStaticSVG() — no dynamic() tricks needed.