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( // ... ) {
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 foreignObject
s:
//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!
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>
);
}