← ./articles

For React Flow Dagre Layouts, Separate Connected Components Instead of Adding a Virtual Root

When a graph has several independent sections, a virtual root looks like a quick fix.

Add one hidden node, connect it to every root, run dagre, and the graph becomes one tree. The problem is that the layout now treats every section as a peer under the same artificial parent. Distinct regions become a flat, wide layout.

For React Flow diagrams, separate connected components first. Lay out each component independently, then place the components side by side.

The symptom: every section starts on the same rank

The graph may contain:

  • startup flow
  • event handlers
  • background jobs
  • dead or orphaned nodes
  • independent state-machine regions

With a virtual root, dagre sees:

__root__
  -> startup
  -> handler_a
  -> handler_b
  -> orphan

That creates one shallow hierarchy. It does not preserve conceptual grouping.

Find weakly connected components

Treat edges as undirected only for grouping:

type NodeLike = { id: string };
type EdgeLike = { source: string; target: string };

function findComponents(nodes: NodeLike[], edges: EdgeLike[]) {
  const neighbors = new Map<string, Set<string>>();
  nodes.forEach((node) => neighbors.set(node.id, new Set()));

  edges.forEach((edge) => {
    neighbors.get(edge.source)?.add(edge.target);
    neighbors.get(edge.target)?.add(edge.source);
  });

  const visited = new Set<string>();
  const components: string[][] = [];

  for (const node of nodes) {
    if (visited.has(node.id)) continue;

    const stack = [node.id];
    const current: string[] = [];
    visited.add(node.id);

    while (stack.length) {
      const id = stack.pop()!;
      current.push(id);

      for (const next of neighbors.get(id) ?? []) {
        if (!visited.has(next)) {
          visited.add(next);
          stack.push(next);
        }
      }
    }

    components.push(current);
  }

  return components;
}

Now each independent graph section can be laid out separately.

Normalize each component around its own center

Dagre returns absolute positions. Normalize each component before applying a section offset:

function normalizeX<T extends { position: { x: number; y: number } }>(nodes: T[]): T[] {
  const minX = Math.min(...nodes.map((node) => node.position.x));
  const maxX = Math.max(...nodes.map((node) => node.position.x));
  const centerX = (minX + maxX) / 2;

  return nodes.map((node) => ({
    ...node,
    position: {
      x: node.position.x - centerX,
      y: node.position.y,
    },
  }));
}

Without this step, a large component and a single-node component can look misaligned even after side-by-side placement.

Place components side by side

After layout and normalization:

const SECTION_GAP = 420;

const positioned = components.flatMap((component, index) => {
  const layouted = layoutWithDagre(component);
  const normalized = normalizeX(layouted);

  return normalized.map((node) => ({
    ...node,
    position: {
      x: node.position.x + index * SECTION_GAP,
      y: node.position.y,
    },
  }));
});

The section width can be fixed at first. For large graphs, compute it from the component bounding box.

When not to use this pattern

Do not use this when:

  • users manually arrange nodes and expect positions to persist
  • the graph is cyclic and needs a different layout model
  • force-directed layout is the product requirement
  • components are not meaningful to the reader

This pattern is strongest for call graphs, dependency graphs, state-machine regions, and analysis tools where separated sections are part of the explanation.

Verification checklist

Check that:

  • each connected component is detected once
  • no edge connects nodes across separated components
  • large and small components are visually centered
  • fitView() frames all components
  • the layout is deterministic for the same graph
  • screenshots show grouping, not one flat row

References