Skip to work

React Flow auto layout with dagre for custom, variable-size nodes

Variable-size nodes break dagre's centering, first paint flickers, and straight chains render with kinked edges. Here is the why and the fix for each.

5 min read

  • react-flow
  • dagre
  • layout
  • typescript

Every React Flow and dagre tutorial shows the same thing: uniform gray boxes, laid out in a neat tree, everything centered. You copy the pattern, wire it up, and it works. Then you replace the gray boxes with real cards. A title that wraps to two lines. A card with a description and one without. And the layout starts to look subtly wrong: a parent sits off-center from its children, edges bend where they should be straight, and everything flashes in the top-left corner for a frame before jumping into place.

I hit all three building an approval-workflow graph for a fintech app. The nodes were cards with variable content, so none of the fixed-size assumptions held. It took a while to understand that these are three separate bugs with three separate causes. So here is each one, why it happens, and the fix. At the end: the small package where I put all of it, so you do not have to rebuild this.

Why dagre centers nodes off-balance (and the bounding-box fix)

dagre centers a parent on the barycenter of its children, meaning the average of their center positions. That is correct when every child is the same size. It is visibly wrong when they are not.

Concrete numbers from a graph I probed. A parent with two children, one 40px tall and one 200px tall. dagre puts the children at centers y=20 and y=180, so the parent lands at their average, y=100. But the visual middle of that group, the midpoint of the bounding box from the top of the small child to the bottom of the tall one, is y=140. The parent is 40px off from where your eye says it should be, and the taller the imbalance, the worse it gets.

The fix is a post-pass on dagre's output: for every parent with two or more children, recompute its cross-axis position as the midpoint of the children's bounding box, walking deepest rank first so children settle before their parents. Join nodes get the mirrored treatment, centered on their parents' box.

Fixing the React Flow layout flicker on first render

React Flow can only measure a node after it renders. dagre needs the sizes before it can lay out. So the naive order is: render at 0,0, measure, lay out, move. The user sees every node stacked in the corner for a frame, then the jump. There is a long-running xyflow discussion about exactly this (xyflow/xyflow#2973).

The sequence that works: seed every node with visibility: hidden so nothing paints. Wire onNodesChange, or the measurements never flow back and useNodesInitialized never flips. When it is true, run the layout with the measured sizes, apply positions, and flip to visibility: visible in the same update.

The trap inside the trap: right after a node resizes, node.measured can hold the stale value for a tick. Read sizes off the dimensions change events instead; they carry the new size at the moment it changes.

Straight edges in React Flow: fixing dagre curved connectors

A chain A to B to C, single edge in and out, should read as one straight line. Instead the connector often steps down and back up (xyflow/xyflow#3218). The cause: a smoothstep edge places its elbow at the midpoint between the two handles, and handles sit at node centers, so different heights mean different centers and the connector bends.

For linear chains, snap the child's cross-axis center onto the source's so the run is colinear. For fan-outs and joins, anchor the elbow to the shared hub so every elbow lines up whatever the node sizes.

A fourth one, which is not a layout problem at all

dagre runs crossing-minimisation, which is the correct thing for a general graph and the wrong thing for an approval flow. It reshuffles fan-out branches to reduce edge crossings, so the order you declared your edges in is not the order they render in.

That matters when the order carries meaning. In an approval flow the branches are not interchangeable, they are a policy someone wrote in a specific sequence and will be asked to defend. So the library keeps fan-out in your declared edge order and accepts the extra crossing. Three of the four fixes here are not smarter graph theory, they are refusing a defensible default that is wrong for this domain.

The library: react-flow-auto-layout

I extracted all of this into a small library. Install it with npm i react-flow-auto-layout. The useAutoLayout hook handles the whole measure-then-layout dance; there is also a pure layout function, plus AlignedStepEdge and withAlignedElbows for the hub-anchored elbows. dagre underneath, dual ESM and CJS, typed, MIT.

const { nodes, edges, onNodesChange, onEdgesChange } =
  useAutoLayout({ nodes: sourceNodes, edges: sourceEdges });
tsx · The measurement dance, hidden behind one hook

Honest limits: the elbow alignment only applies to step edges, and dagre suits the graph sizes React Flow is typically used at, tens of nodes in a few milliseconds, not thousands. The measurement pass also costs a hidden render, which is invisible at approval-flow sizes and would not be at graph-explorer sizes. I have not measured where that stops being free, so I am not going to claim a number for it.

It lays out the canvas in approvals-ui and the approval flow in ledgerloop. npm · source · live demo.