Stately
PackagesGraph

React Flow + ELK: automatic layout cookbook

A recipe for building a React Flow (xyflow v12) node graph UI where layout is computed by ELK and the graph — not React Flow's node array — is the single source of truth.

A recipe for building a React Flow (xyflow v12) node-graph UI where layout is computed by ELK and the graph — not React Flow's node array — is the single source of truth.

The pipeline

domain model ─createGraph→ @statelyai/graph ─getElkLayout→ VisualGraph
                    ▲                                          │ toXYFlow
                    └────── fromXYFlow ◄── user edits ◄── <ReactFlow>

Layout is a pure function: getElkLayout(graph) returns a new VisualGraph with node positions/sizes, routed edge points, and edge-label rects. Child node coordinates are parent-relative — the same convention React Flow uses, so positions map 1:1.

1. Build the graph

import { createGraph } from '@statelyai/graph';

const graph = createGraph({
  id: 'pipeline',
  nodes: [
    { id: 'fetch', label: 'Fetch', ports: [{ name: 'result', direction: 'out' }] },
    { id: 'transform', label: 'Transform', parentId: 'stage' },
    { id: 'stage', label: 'Stage' }, // compound parent
    { id: 'render', label: 'Render', ports: [{ name: 'input', direction: 'in' }] },
  ],
  edges: [
    { id: 'e1', sourceId: 'fetch', sourcePort: 'result', targetId: 'transform' },
    { id: 'e2', sourceId: 'transform', targetId: 'render', targetPort: 'input' },
  ],
});

2. Layout, convert, render

import { useEffect, useState } from 'react';
import { ReactFlow, type Node, type Edge } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { getElkLayout } from '@statelyai/graph/layout/elk';
import { toXYFlow } from '@statelyai/graph/xyflow';

export function Diagram() {
  const [nodes, setNodes] = useState<Node[]>([]);
  const [edges, setEdges] = useState<Edge[]>([]);

  useEffect(() => {
    let cancelled = false;
    getElkLayout(graph, { algorithm: 'layered', direction: 'right' }).then(
      (laidOut) => {
        if (cancelled) return;
        const flow = toXYFlow(laidOut);
        setNodes(flow.nodes as Node[]);
        setEdges(flow.edges as Edge[]);
      },
    );
    return () => { cancelled = true; };
  }, []);

  return <ReactFlow nodes={nodes} edges={edges} fitView />;
}

toXYFlow maps node.x/y → position.{x,y} and width/height directly, emits parents before children (a React Flow requirement), and maps edge.sourcePort/targetPort → sourceHandle/targetHandle. Everything it can't express natively (label, color, ports, weight, …) rides along under a reserved __statelyai key in data, so fromXYFlow can round-trip it losslessly.

Two render-side notes:

  • Labels land where the renderers read them: edge labels on the top-level edge.label (what React Flow's built-in edges render) and node labels on data.label (what the default node renders). fromXYFlow reads both spots back, so external React Flow objects import cleanly too.
  • node.shape becomes React Flow's type. Only set shape to values you've registered in nodeTypes, or React Flow falls back to the default node.

3. Measure real node sizes (two-pass)

ELK needs node dimensions before it can lay anything out, but real dimensions come from the DOM. The honest pattern is two passes: render → measure → layout → re-render. React Flow measures nodes for you and exposes the result on node.measured; useNodesInitialized() tells you when that's done.

import { useNodesInitialized, useReactFlow } from '@xyflow/react';
import { DEFAULT_NODE_SIZE } from '@statelyai/graph/layout';

function useElkLayout(setNodes: (n: Node[]) => void, setEdges: (e: Edge[]) => void) {
  const nodesInitialized = useNodesInitialized();
  const { getNodes } = useReactFlow();

  useEffect(() => {
    if (!nodesInitialized) return; // pass 1 rendered at (0,0); DOM now measured
    const sizes = new Map(getNodes().map((n) => [n.id, n.measured]));

    getElkLayout(graph, {
      measure: (node) => {
        const m = sizes.get(node.id);
        return m?.width && m?.height
          ? { width: m.width, height: m.height }
          : DEFAULT_NODE_SIZE;
      },
    }).then((laidOut) => {
      const flow = toXYFlow(laidOut);
      setNodes(flow.nodes as Node[]);
      setEdges(flow.edges as Edge[]);
    });
  }, [nodesInitialized]);
}

Pass 1 renders toXYFlow(graph) with whatever positions exist (all 0,0 for a fresh graph — hide or fade the canvas to avoid a flash). Sizes resolve as measure → the node's own width/heightDEFAULT_NODE_SIZE (100×50); layout adapters never guess text sizes. Reuse measured sizes on later runs.

4. Layout in a web worker

ELK layouts of large graphs can block the main thread. elkjs can run in a worker, and getElkLayout accepts any pre-constructed instance via its elk option (anything with a layout(graph): Promise method):

import ELK from 'elkjs/lib/elk-api'; // slim API — no bundled engine
import { getElkLayout } from '@statelyai/graph/layout/elk';

const elk = new ELK({
  workerFactory: () =>
    new Worker(new URL('elkjs/lib/elk-worker.min.js', import.meta.url)),
});
// or, serving the worker file yourself:
// const elk = new ELK({ workerUrl: '/elk-worker.min.js' });

const laidOut = await getElkLayout(graph, { elk });

Without elk, the adapter constructs a default in-process new ELK() once and reuses it.

5. Smooth re-layout

When the graph changes (node added, edge removed), lay out again and tween between the old and new positions. genLayoutTransition yields interpolated frames; applyLayoutFrame writes a frame's positions onto the graph in place. Drive it with requestAnimationFrame:

import {
  genLayoutTransition,
  applyLayoutFrame,
} from '@statelyai/graph/layout';

let current: VisualGraph = laidOut; // last settled layout

async function relayout() {
  const next = await getElkLayout(current, { elk });
  const frames = genLayoutTransition(current, next, { steps: 20 });

  const tick = () => {
    const { value, done } = frames.next();
    if (done) {
      current = value; // generator returns the settled VisualGraph
      const flow = toXYFlow(current);
      setNodes(flow.nodes as Node[]);
      setEdges(flow.edges as Edge[]);
      return;
    }
    applyLayoutFrame(current, value); // mutate positions in place
    setNodes(toXYFlow(current).nodes as Node[]);
    requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
}

Frames carry node positions only — edge routes are not interpolated, so apply the final edge geometry once at the end (as above) or hide edges mid-tween. Alternatively, skip the tween entirely and just setNodes with the new layout; add CSS transitions on .react-flow__node if you want easing without a loop. getLayoutBounds(graph) and centerGraph(graph, rect) help keep a fresh layout centered in your viewport before converting.

6. Compound nodes and ports

Compound: ELK is the engine of choice here — parentId hierarchy is first-class, parents are sized around their children, and child coordinates come back parent-relative, which is exactly what React Flow expects. toXYFlow sets parentId on child nodes and orders parents first. It does not set extent — add it yourself if children should stay confined:

const nodes = flow.nodes.map((n) =>
  n.parentId ? { ...n, extent: 'parent' as const } : n,
) as Node[];

Ports: edges reference ports by name, and toXYFlow maps them to sourceHandle/targetHandle. The port definitions themselves travel in node metadata — render them as handles in a custom node, with Handle.id equal to the port name:

import { Handle, Position, type NodeProps } from '@xyflow/react';

function PortNode({ data }: NodeProps) {
  const meta = (data as any).__statelyai?.node;
  return (
    <div className="port-node">
      {meta?.label}
      {(meta?.ports ?? []).map((port: { name: string; direction?: string }) => (
        <Handle
          key={port.name}
          id={port.name} // must match — edges point at it via sourceHandle/targetHandle
          type={port.direction === 'in' ? 'target' : 'source'}
          position={port.direction === 'in' ? Position.Left : Position.Right}
        />
      ))}
    </div>
  );
}

Port direction is advisory; ELK uses it for port-side placement during layout, and you choose how to render it.

7. Back to the graph

After the user drags nodes or rewires edges, fold React Flow's state back into a graph with fromXYFlow. Keep the data blob from toXYFlow — it carries graph-level metadata (id, mode, direction, …):

import { fromXYFlow } from '@statelyai/graph/xyflow';

const updated = fromXYFlow({
  nodes: getNodes(),
  edges: getEdges(),
  data: flowData, // the `data` returned by toXYFlow
});

fromXYFlow prefers node.measured sizes (React Flow's DOM measurements) over declared width/height, maps sourceHandle/targetHandle back to ports, and restores original node/edge data from the __statelyai metadata. The result is a VisualGraph — your single source of truth — ready for the next getElkLayout pass, persistence, or any of the library's queries and algorithms.

On this page