Check out bidbear.io Amazon Advertising for Humans. Now publicly available 🚀

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

vertical dagre layout react flow 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.

"VerticalDagre.js"
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.

horizontal react flow dagre diagram

Custom Nodes with Dagre Layout

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.

DagreNodePositioning.js
// 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.

AdStructureDiagram.js
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.

Amazon Ad Analytics For Humans

Advertising reports automatically saved and displayed beautifully for powerful insights.

bidbear.io
portfolios page sunburst chart