React Flow Dagre Layout with Custom Nodes
Intro
In React Flow there didn't seem to be a good way to implement Dagre Layout while using custom nodes. Let's fix that.
Vertical Dagre Diagram
Dagre refers to the layout library, which we have to use if we don't want to position all of our elements on the canvas by hand.
Here is a functional sample component that generates a vertical react-flow diagram using Dagre.
import React, { useCallback } from "react";
import ReactFlow, {
addEdge,
ConnectionLineType,
useNodesState,
useEdgesState,
Background,
Controls,
} from "reactflow";
import dagre from "dagre";
import "reactflow/dist/style.css";
const initialNodes = [
{
id: "1",
position: { x: 0, y: 0 },
data: { label: "Product Marketing" },
},
{
id: "2",
position: { x: 0, y: 100 },
data: { label: "Advertising" },
},
{
id: "3a",
position: { x: 0, y: 200 },
data: { label: "Amazon Advertising" },
},
{
id: "3b",
position: { x: 0, y: 200 },
data: { label: "Google Advertising" },
},
];
const initialEdges = [
{ id: "e1-2", source: "1", target: "2", animated: true },
{ id: "e1-3", source: "2", target: "3a", animated: true },
{ id: "e1-4", source: "2", target: "3b", animated: true },
];
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const nodeWidth = 172;
const nodeHeight = 36;
const getLayoutedElements = (nodes, edges, direction) => {
const isHorizontal = direction === "LR";
dagreGraph.setGraph({ rankdir: direction });
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
nodes.forEach((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
node.targetPosition = isHorizontal ? "left" : "top";
node.sourcePosition = isHorizontal ? "right" : "bottom";
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
node.position = {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
};
return node;
});
return { nodes, edges };
};
// graph direction options
// TB - top to bottom
// BT - bottom to top
// LR - left to right
// RL - right to left
const direction = "TB";
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
initialNodes,
initialEdges,
direction
);
const LayoutFlow = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
const onConnect = useCallback(
(params) =>
setEdges((eds) =>
addEdge(
{ ...params, type: ConnectionLineType.SmoothStep, animated: true },
eds
)
),
[]
);
return (
<div className="react-flow-container">
<div style={{ width: "100%", height: "100%" }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
>
<Controls position="top-left" />
<Background color="#aaa" gap={16} />
</ReactFlow>
</div>
</div>
);
};
export default LayoutFlow;
The css for the container is this:
.react-flow-container {
width: 100%;
height: 500px;
overflow: hidden;
border: 1px solid #eee;
border-radius: 0.5rem;
}
Horizontal Dagre Diagram
The nice thing about the previous example is that we can convert it to a horizontal layout easily by just changing the direction
variable. More details on those options at the Dagre Wiki.
Custom Nodes with Dagre Layout
One of the first issues when we try to use custom nodes with an automated dagre layout is that our nodes won't have fixed dimensions, and the dimensions are one of the things that the Dagre library needs to successfully position the nodes.
To get the node dimensions we will need to access the React Flow state. Unfortunately that state is managed with a library called Zustand, which is incredibly frustrating to use in this context because it cannot be accessed unless it is being called from a child component of the ReactFlowProvider.
So in a nutshell, we have to let React Flow draw the custom nodes on the screen, the dimensions of those nodes will be saved to state, where we will then use those dimensions with the Dagre library to calculate new positions for them, and then save those updated nodes back to the React Flow state.
After many hours of tinkering I got this functional and wrapped up in a nice little component.
// component as child of ReactFlowProvider so we have access to state
import { useState, useEffect } from "react";
import { useStore, useReactFlow } from "reactflow";
import dagre from "dagre";
const DagreNodePositioning = ({
Options,
SetNodes,
SetEdges,
Edges,
SetViewIsFit,
}) => {
const [nodesPositioned, setNodesPositioned] = useState(false);
const { fitView } = useReactFlow();
// fetch react flow state
const store = useStore();
// isolate nodes map
const nodeInternals = store.nodeInternals;
// flatten nodes map to array
const flattenedNodes = Array.from(nodeInternals.values());
useEffect(() => {
try {
// node dimensions are not immediately detected, so we want to wait until they are
if (flattenedNodes[0]?.width) {
// create dagre graph
const dagreGraph = new dagre.graphlib.Graph();
// this prevents error
dagreGraph.setDefaultEdgeLabel(() => ({}));
// use dagre graph to layout nodes
const getLayoutedElements = (nodes, edges) => {
dagreGraph.setGraph(Options);
edges.forEach((edge) => dagreGraph.setEdge(edge.source, edge.target));
nodes.forEach((node) => dagreGraph.setNode(node.id, node));
dagre.layout(dagreGraph);
return {
nodes: nodes.map((node) => {
const { x, y } = dagreGraph.node(node.id);
return { ...node, position: { x, y } };
}),
edges,
};
};
// if nodes exist and nodes are not positioned
if (flattenedNodes.length > 0 && !nodesPositioned) {
const layouted = getLayoutedElements(flattenedNodes, Edges);
// ad target positions based on chart direction
switch (Options.rankdir) {
case "TB":
layouted.nodes.forEach((node) => {
node.targetPosition = "top";
node.sourcePosition = "bottom";
});
break;
case "BT":
layouted.nodes.forEach((node) => {
node.targetPosition = "bottom";
node.sourcePosition = "top";
});
break;
case "LR":
layouted.nodes.forEach((node) => {
node.targetPosition = "left";
node.sourcePosition = "right";
});
break;
case "RL":
layouted.nodes.forEach((node) => {
node.targetPosition = "right";
node.sourcePosition = "left";
});
break;
default:
console.log("unrecognized chart direction");
}
// update react flow state
SetNodes(layouted.nodes);
SetEdges(layouted.edges);
setNodesPositioned(true);
// fit view
window.requestAnimationFrame(() => {
fitView();
});
SetViewIsFit(true);
}
} else {
return null;
}
} catch (error) {
console.log("error", error);
return null;
}
});
return null;
};
export default DagreNodePositioning;
Then you can simply integrate that component into your very simple diagram which is using custom nodes.
import React, { useState } from "react";
import ReactFlow, {
ReactFlowProvider,
useNodesState,
useEdgesState,
ConnectionLineType,
Background,
Controls,
} from "reactflow";
import "reactflow/dist/style.css";
// custom nodes
import {
PortfolioNode,
CampaignNode,
AdGroupNode,
AdNode,
} from "./CustomNodes.js";
import DagreNodePositioning from "../../../../src/components/react-flow/DagreNodePositioning.js";
const nodeTypes = {
portfolioNode: PortfolioNode,
campaignNode: CampaignNode,
adGroupNode: AdGroupNode,
adNode: AdNode,
};
// position will be set by dagre
let position = { x: 0, y: 0 };
const initialNodes = [
{
id: "portfolio",
type: "portfolioNode",
position,
draggable: false,
},
{
id: "campaign",
type: "campaignNode",
position,
draggable: false,
},
{
id: "adgroup",
type: "adGroupNode",
position,
draggable: false,
},
{
id: "adgroup-2",
type: "adGroupNode",
position,
draggable: false,
},
{
id: "ad",
type: "adNode",
position,
draggable: false,
},
{
id: "ad-2",
type: "adNode",
position,
draggable: false,
},
{
id: "ad-3",
type: "adNode",
position,
draggable: false,
},
];
// { id: "e1-2", source: "root", target: "advertising", animated: true }
const initialEdges = [
// section 1
{ id: "e1", source: "portfolio", target: "campaign", animated: true },
{ id: "e2", source: "campaign", target: "adgroup", animated: true },
{ id: "e3", source: "adgroup", target: "ad", animated: true },
{ id: "e4", source: "campaign", target: "adgroup-2", animated: true },
{ id: "e5", source: "adgroup-2", target: "ad-2", animated: true },
{ id: "e6", source: "adgroup-2", target: "ad-3", animated: true },
];
// https://github.com/dagrejs/dagre/wiki#configuring-the-layout
const dagreOptions = { rankdir: "TB" };
const Diagram = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [viewIsFit, setViewIsFit] = useState(false);
// 📝would like to add some future improvement using viewIsFit state where the nodes are hidden until they are positioned
// however the build in react flow hidden method removes the node dimensions from state
// making it impossible to position the nodes when they are hidden...
return (
<div className={`react-flow-container }`}>
<div style={{ width: "100%", height: "100%" }}>
<ReactFlowProvider>
<DagreNodePositioning
Options={dagreOptions}
Edges={edges}
SetEdges={setEdges}
SetNodes={setNodes}
SetViewIsFit={setViewIsFit}
/>
<ReactFlow
nodeTypes={nodeTypes}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
connectionLineType={ConnectionLineType.SmoothStep}
>
<Controls position="top-left" />
<Background color="#aaa" gap={16} />
</ReactFlow>
</ReactFlowProvider>
</div>
</div>
);
};
export default Diagram;
You'll also notice there that we have a simple dagre options object that can be passed to DagreNodePositioning
to adjust any settings there.
Lastly if you want the edge source and target to change location with the graph you have to structure your custom node like this.
export function AdGroupNode({ id, sourcePosition, targetPosition }) {
return (
<>
<div className="auto-width-node">
<div className="header-node-header">
<label>Ad Group</label>
</div>
<div className="flex-column header-node-body">
<span>- contains ads</span>
<span>- may contain one ad type</span>
<strong>
<span>- may assign targets</span>
</strong>
<span>- may assign negative targets</span>
</div>
</div>
<Handle type="target" position={targetPosition} id={id} />
<Handle type="source" position={sourcePosition} id={id} />
</>
);
}
export function AdNode({ id, targetPosition }) {
return (
<>
<div className="auto-width-node">
<div className="header-node-header">
<label>Ad </label>
</div>
<div className="flex-column header-node-body">
<p>Format of ad will depend on ad type</p>
</div>
</div>
<Handle type="target" position={targetPosition} id={id} />
</>
);
}
Where the source and target are assigned with props and not hardcoded to the custom node. Otherwise the dynamic target positions that we have saved to the React Flow state will be overwritten.
Comments
Recent Work
Basalt
basalt.softwareFree desktop AI Chat client, designed for developers and businesses. Unlocks advanced model settings only available in the API. Includes quality of life features like custom syntax highlighting.
BidBear
bidbear.ioBidbear is a report automation tool. It downloads Amazon Seller and Advertising reports, daily, to a private database. It then merges and formats the data into beautiful, on demand, exportable performance reports.