Rendering graph nodes as React components in d3.js+React graph.

Today, let's create a graph component that uses React and d3.js. Usually when someone speaks about graph in d3.js, they mean something like this. The entities are shown as a circles and relationship between them — as line (they call them "edge" or "link") connecting them. For many cases that representation is more than enough. But recently I've met the necessity to render a react component instead of just simple circle. Doing that would give us great freedom in ways to display neatly the information in the node, because the main pain point in SVG is inability to comfortably deal with text, flexbox etc. So first things first, we would need a Vite+React+Typescript project (I will use bun as package manager) bun create vite After installation finishes, we need to add some packages to render the graph: cd bun add d3-force d3-selection Also, we would need a type definitions for d3 packages, which comes separately: bun add -D @types/d3-force @types/d3-selection Now we are good to go. First we will need to define the type of data: // Graph.tsx interface GraphNode = { id: string; name: string; // some text to show on the node url: string; // some additional info }; interface GraphLink = { source: string; // id of the source node target: string; // id of the target node strength: number; // strength of the link }; The d3-force package has Typescript types for generic nodes and links, so we would want to use them for type safety. We will extend our interfaces from the generics from d3-force: // Graph.tsx import { SimulationLinkDatum, SimulationNodeDatum } from "d3-force"; interface GraphNode extends SimulationNodeDatum { id: string; name: string; url: string; }; interface GraphLink extends SimulationLinkDatum { strength: number; }; (SimulationLinkDatum already has source and target fields) So now let's define our Graph component: // Graph.tsx // ... type definitions function Graph({ nodes, links, width = 300, height = 400, }: { nodes: GraphNode[]; links: GraphLink[]; width: number; height: number; }) { return ; } Data we want to visualize would look something like this: const nodes: GraphNode[] = [ { id: "1", name: "Node 1", url: "https://example.com", }, { id: "2", name: "Node 2", url: "https://google.com", }, { id: "3", name: "Node 3", url: "https://yahoo.com", }, { id: "4", name: "Node 4", url: "https://x.com", } ] const links: GraphLink[] = [ { source: "1", target: "2", strength: 1, }, { source: "2", target: "3", strength: 2, }, { source: "3", target: "4", strength: 3, }, { source: "4", target: "1", strength: 4, } ] Cool. Now we need to construct new links, so the source and target would contain the node objects themselves: // inside the Graph component const filledLinks = useMemo(() => { const nodesMap = new Map(nodes.map((node) => [node.id, node])); return links.map((link) => ({ source: nodesMap.get(link.source as string)!, target: nodesMap.get(link.target as string)!, strength: link.strength, label: link.label, })); }, [nodes, links]); Now we need to get to rendering stuff with d3.js and React. Let's create our force simulation object. // ... outside Graph component const LINK_DISTANCE = 90; const FORCE_RADIUS_FACTOR = 2; const NODE_STRENGTH = -100; const simulation = forceSimulation() .force("charge", forceManyBody().strength(NODE_STRENGTH)) .force("collision", forceCollide(RADIUS * FORCE_RADIUS_FACTOR)) We place that simulation definition with all the settings that are not dependent on the concrete data outside our React component, in order to escape creating new simulation every time our component rerenders. There is one peculiarity in the way d3-force is handling it's data: it will mutate the passed nodes and links data "in-place". It will add some information about the node's coordinate x, y, it's velocity vector vx,vy and some other info — right into the corresponding element of our nodes array, without reassigning them. So React won't "notice" that the node's x and y has changed. And that is quite beneficial for us, because that won't cause unnecessary rerenders on every simulation "tick". Now, when we have our simulation object almost ready, let's get to linking it with the real data: // Graph.tsx, inside the component: // ... useEffect(() => { simulation .nodes(nodes) .force( "link", forceLink(filledLinks) .id((d) => d.id) .distance(LINK_DISTANCE), ) .force("center", forceCenter(width / 2, height / 2).strength(0.05)); }, [height, width, nodes, filledLinks]); We need to bind the d3 to the svg, that is rendered by React, so we will add a ref: // Graph.tsx, inside component function Graph( // ... ) {

Jan 13, 2025 - 12:14
 0
Rendering graph nodes as React components in d3.js+React graph.

Today, let's create a graph component that uses React and d3.js.
Usually when someone speaks about graph in d3.js, they mean something like this. The entities are shown as a circles and relationship between them — as line (they call them "edge" or "link") connecting them.
For many cases that representation is more than enough. But recently I've met the necessity to render a react component instead of just simple circle. Doing that would give us great freedom in ways to display neatly the information in the node, because the main pain point in SVG is inability to comfortably deal with text, flexbox etc.

So first things first, we would need a Vite+React+Typescript project (I will use bun as package manager)

bun create vite

After installation finishes, we need to add some packages to render the graph:

cd 
bun add d3-force d3-selection

Also, we would need a type definitions for d3 packages, which comes separately:

bun add -D @types/d3-force @types/d3-selection

Now we are good to go.

First we will need to define the type of data:

// Graph.tsx
interface GraphNode = {
  id: string;
  name: string; // some text to show on the node
  url: string; // some additional info
};

interface GraphLink = {
  source: string; // id of the source node
  target: string; // id of the target node
  strength: number; // strength of the link
};

The d3-force package has Typescript types for generic nodes and links, so we would want to use them for type safety.
We will extend our interfaces from the generics from d3-force:

// Graph.tsx

import { SimulationLinkDatum, SimulationNodeDatum } from "d3-force";

interface GraphNode extends SimulationNodeDatum {
  id: string;
  name: string;
  url: string;
};

interface GraphLink extends SimulationLinkDatum<GraphNode> {
  strength: number;
}; 

(SimulationLinkDatum already has source and target fields)

So now let's define our Graph component:

// Graph.tsx

// ... type definitions 

function Graph({
  nodes,
  links,
  width = 300,
  height = 400,
}: {
  nodes: GraphNode[];
  links: GraphLink[];
  width: number;
  height: number;
}) {
  return <svg width={width} height={height}>svg>;
}

Data we want to visualize would look something like this:

const nodes: GraphNode[] = [
  {
    id: "1",
    name: "Node 1",
    url: "https://example.com",
  },
  {
    id: "2",
    name: "Node 2",
    url: "https://google.com",
  },
  {
    id: "3",
    name: "Node 3",
    url: "https://yahoo.com",
  },
  {
    id: "4",
    name: "Node 4",
    url: "https://x.com",
  }
]

const links: GraphLink[] = [
  {
    source: "1",
    target: "2",
    strength: 1,
  },
  {
    source: "2",
    target: "3",
    strength: 2,
  },
  {
    source: "3",
    target: "4",
    strength: 3,
  },
  {
    source: "4",
    target: "1",
    strength: 4,
  }
]

Cool. Now we need to construct new links, so the source and target would contain the node objects themselves:

// inside the Graph component

  const filledLinks = useMemo(() => {
    const nodesMap = new Map(nodes.map((node) => [node.id, node]));

    return links.map((link) => ({
      source: nodesMap.get(link.source as string)!,
      target: nodesMap.get(link.target as string)!,
      strength: link.strength,
      label: link.label,
    }));
  }, [nodes, links]);

Now we need to get to rendering stuff with d3.js and React.
Let's create our force simulation object.

// ... outside Graph component
const LINK_DISTANCE = 90;
const FORCE_RADIUS_FACTOR = 2;
const NODE_STRENGTH = -100;

const simulation = forceSimulation<GraphNode, GraphLink>()
      .force("charge", forceManyBody().strength(NODE_STRENGTH))
      .force("collision", forceCollide(RADIUS * FORCE_RADIUS_FACTOR))

We place that simulation definition with all the settings that are not dependent on the concrete data outside our React component, in order to escape creating new simulation every time our component rerenders.

There is one peculiarity in the way d3-force is handling it's data: it will mutate the passed nodes and links data "in-place". It will add some information about the node's coordinate x, y, it's velocity vector vx,vy and some other info — right into the corresponding element of our nodes array, without reassigning them.
So React won't "notice" that the node's x and y has changed. And that is quite beneficial for us, because that won't cause unnecessary rerenders on every simulation "tick".

Now, when we have our simulation object almost ready, let's get to linking it with the real data:


// Graph.tsx, inside the component:
// ...

  useEffect(() => {
    simulation
      .nodes(nodes)
      .force(
        "link",
        forceLink<GraphNode, GraphLink>(filledLinks)
          .id((d) => d.id)
          .distance(LINK_DISTANCE),
      )
      .force("center", forceCenter(width / 2, height / 2).strength(0.05));

  }, [height, width, nodes, filledLinks]);

We need to bind the d3 to the svg, that is rendered by React, so we will add a ref:


// Graph.tsx, inside component

function Graph(
// ...
 ) {

const svgRef = useRef<SVGSVGElement>()

// ...

  return <svg width={width} height={height} ref={svgRef}> svg>
}

Now we will render our links and nodes.


//Graph.tsx inside component, inside the useEffect that we set up earlier

    const linksSelection = select(svgRef.current)
      .selectAll("line.link")
      .data(filledLinks)
      .join("line")
      .classed("link", true)
      .attr("stroke-width", d => d.strength)
      .attr("stroke", "black");

For nodes o four graph to be React components, and not just some svg , we will use a SVG element :

//Graph.tsx inside component, inside the useEffect that we set up earlier

const linksSelection = // ...

const nodesSelection = select(svgRef.current)
      .selectAll("foreignObject.node")
      .data(nodes)
      .join("foreignObject")
      .classed("node", true)
      .attr("width", 1)
      .attr("height", 1)
      .attr("overflow", "visible"); 

That will render an empty node as our nodes. Notice that we setting width and height of it, as well as setting overflow: visible. That will help us to render react component of arbitrary size for our nodes.

Now we need to render a react component inside the foreignObject. We will do that using createRoot function from react-dom/client, as it is done for the root component to bind React to DOM.
So we will iterate over all created foreignObjects:

//Graph.tsx inside component, inside the useEffect that we set up earlier

const linksSelection = // ...
const nodesSelection = // ...

nodesSelection?.each(function (node) {
      const root = createRoot(this as SVGForeignObjectElement);
      root.render(
        <div className="z-20 w-max -translate-x-1/2 -translate-y-1/2">
          <Node name={node.name} />
        div>,
      );
    });

this in the callback function is a DOM node.

The z-20 w-max -translate-x-1/2 -translate-y-1/2 classes adjusts position of our node for it to be centered around the node's coordinates.

The Node component is arbitrary React component. In my case, it's like this:

// Node.tsx

export function Node({ name }: { name: string }) {
  return (
    <div className=" bg-blue-300 rounded-full border border-blue-800 px-2">
      {name}
    div>
  );
}

Next, we will tell d3 to adjust positions of the nodes and links on every iteration ("tick") of the force simulation:

//Graph.tsx inside component, inside the useEffect that we set up earlier
const linksSelection = // ...
const nodesSelection = // ...

nodesSelection?.each.( 
  //...
);

simulation.on("tick", () => {
      linksSelection
        .attr("x1", (d) => d.source.x!)
        .attr("y1", (d) => d.source.y!)
        .attr("x2", (d) => d.target.x!)
        .attr("y2", (d) => d.target.y!);

      nodesSelection.attr("transform", (d) => `translate(${d.x}, ${d.y})`);
    });

Voila! We have a d3 graph with force simulation and our nodes are rendered as a React components!

End result - Graph with d3 force simulation and nodes as React components

The full code for the Graph component (I've adjusted the Node.tsx a little bit to show the othe data that belongs to the node):

//Graph.tsx
import {
  forceCenter,
  forceCollide,
  forceLink,
  forceManyBody,
  forceSimulation,
  SimulationLinkDatum,
  SimulationNodeDatum,
} from "d3-force";
import { select } from "d3-selection";
import { useEffect, useMemo, useRef } from "react";
import { createRoot } from "react-dom/client";
import { Node } from "./Node";

const RADIUS = 10;
const LINK_DISTANCE = 150;
const FORCE_RADIUS_FACTOR = 10;
const NODE_STRENGTH = -100;

export interface GraphNode extends SimulationNodeDatum {
  id: string;
  name: string;
  url: string;
}

export interface GraphLink extends SimulationLinkDatum<GraphNode> {
  strength: number;
  label: string;
}

const nodes: GraphNode[] = [
  {
    id: "1",
    name: "Node 1",
    url: "https://example.com",
  },
  {
    id: "2",
    name: "Node 2",
    url: "https://google.com",
  },
  {
    id: "3",
    name: "Node 3",
    url: "https://yahoo.com",
  },
  {
    id: "4",
    name: "Node 4",
    url: "https://x.com",
  },
];

const links: GraphLink[] = [
  {
    source: "1",
    target: "2",
    strength: 1,
  },
  {
    source: "2",
    target: "3",
    strength: 2,
  },
  {
    source: "3",
    target: "4",
    strength: 3,
  },
  {
    source: "4",
    target: "1",
    strength: 4,
  },
];

const simulation = forceSimulation<GraphNode, GraphLink>()
  .force("charge", forceManyBody().strength(NODE_STRENGTH))
  .force("collision", forceCollide(RADIUS * FORCE_RADIUS_FACTOR));

function Graph({
  nodes,
  links,
  width = 600,
  height = 400,
}: {
  nodes: GraphNode[];
  links: GraphLink[];
  width?: number;
  height?: number;
}) {
  const svgRef = useRef<SVGSVGElement>(null);

  const filledLinks = useMemo(() => {
    const nodesMap = new Map(nodes.map((node) => [node.id, node]));
    return links.map((link) => ({
      source: nodesMap.get(link.source as string)!,
      target: nodesMap.get(link.target as string)!,
      strength: link.strength,
      label: link.label,
    }));
  }, [nodes, links]);

  useEffect(() => {
    simulation
      .nodes(nodes)
      .force(
        "link",
        forceLink<GraphNode, GraphLink>(filledLinks)
          .id((d) => d.id)
          .distance(LINK_DISTANCE),
      )
      .force("center", forceCenter(width / 2, height / 2).strength(0.05));

    const linksSelection = select(svgRef.current)
      .selectAll("line.link")
      .data(filledLinks)
      .join("line")
      .classed("link", true)
      .attr("stroke-width", (d) => d.strength)
      .attr("stroke", "black");

    const nodesSelection = select(svgRef.current)
      .selectAll("foreignObject.node")
      .data(nodes)
      .join("foreignObject")
      .classed("node", true)
      .attr("width", 1)
      .attr("height", 1)
      .attr("overflow", "visible");

    nodesSelection?.each(function (node) {
      const root = createRoot(this as SVGForeignObjectElement);
      root.render(
        <div className="z-20 w-max -translate-x-1/2 -translate-y-1/2">
          <Node node={node} />
        div>,
      );
    });

    simulation.on("tick", () => {
      linksSelection
        .attr("x1", (d) => d.source.x!)
        .attr("y1", (d) => d.source.y!)
        .attr("x2", (d) => d.target.x!)
        .attr("y2", (d) => d.target.y!);

      nodesSelection.attr("transform", (d) => `translate(${d.x}, ${d.y})`);
    });
  }, [height, width, nodes, filledLinks]);

  return <svg width={width} height={height} ref={svgRef}>svg>;
}

export { Graph, links, nodes };
// Node.tsx
import { GraphNode } from "./Graph";

export function Node({ node }: { node: GraphNode }) {
  return (
    <div className=" bg-blue-300 rounded-full border border-blue-800 px-5 py-1">
      <h2>{node.name} h2>
      <a className=" inline-block text-sm underline" href={node.url}>
        {node.url}
      a>
    div>
  );
}