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