React for Data Visualization
Student Login
  • Introduction

Build force directed graphs with React and D3v7

A common question readers ask is how to make D3 force-directed graphs work with React. It's a little tricky.

You'd think it's obvious – use D3 to drive the force simulation, React for rendering. That's the approach I teach in React For Dataviz.

But something changed in recent versions of D3 and I racked my brain for hours. It nearly killed me. 😂

Swizec Teller writing a secret book avatarSwizec Teller writing a secret book@Swizec
this was fun to build

and by fun I mean that multi-focal force graphs in React and D3 almost broke me 😅

time to expand , looks like we got a new chapter for the cookbook!
Tweet media

What are force-directed graphs

Force-directed graphs are one of D3's magic tricks. A feature that demos nicely and solves an important problem.

You can use them to layout complex data.

Force-directed tree

Simulate collisions.

Collision detection

And even cloth simulations, if you squint hard enough.

Cloth simulation

Finding the best layout for a complex graph is hard. Even impossible in some cases. Force-directed graphs solve the problem by simulating physical forces between nodes, which leads to visually pleasing results.

The downside is that you have to wait for the simulation. You can pre-generate the final result before rendering, but you're waiting at some point somewhere.

Luckily the animations look nice 😊

Build a force-directed graph with React and D3

D3's d3-force module gives you the tools to simulate forces.

You create a new simulation with d3.forceSimulation(), add different forces with .force('name', func), pass-in data with .nodes(), and update your visuals on each tick of the animation with .on('tick', func).

Where it gets tricky in a React context is that d3.forceSimulation() assumes it's running on DOM nodes directly. It wants to change attributes.

Force-less React & D3 force-directed graph

Let's start with a force simulation that has no forces.

Nothing happens so the simulation is not very interesting. But it sets us up.

We have a ForceGraph component that accepts a list of nodes as its prop and renders a circle for each. This is the "React handles rendering" part.

function ForceGraph({ nodes }) {
const [animatedNodes, setAnimatedNodes] = useState([])
// ...
return (
<g>
{animatedNodes.map((node) => (
<circle
cx={node.x}
cy={node.y}
r={node.r}
key={node.id}
stroke="black"
fill="transparent"
/>
))}
</g>
)
}

We render from animatedNodes in state instead of the nodes prop. Our force simulation is going to change these values over time, which means they have to become part of state.

The animation runs in a useEffect.

// re-create animation every time nodes change
useEffect(() => {
const simulation = d3.forceSimulation()
// update state on every frame
simulation.on("tick", () => {
setAnimatedNodes([...simulation.nodes()])
})
// copy nodes into simulation
simulation.nodes([...nodes])
// slow down with a small alpha
simulation.alpha(0.1).restart()
// stop simulation on unmount
return () => simulation.stop()
}, [nodes])

Any time the ForceGraph component mounts or the nodes prop changes, we start a new simulation.

Visual updates happen by updating component state on every tick of the animation. You can think of these as frames.

// update state on every frame
simulation.on("tick", () => {
setAnimatedNodes([...simulation.nodes()])
})

You have to make a copy of the nodes array or React won't realize there's a change. d3.forceSimulation mutates state in-place, which fails the JavaScript equality test – it's the same object.

simulation.nodes() returns the current state of our simulation.

We return simulation.stop() to clean up after ourselves when the component unmounts or a new effect starts.

Add a centering force

Let's add a centering force to make our visualization more visible. You can have one centering force per graph.

We've got some action now! The cluster of nodes moves towards the (400, 300) coordinates at the center of our SVG.

const simulation = d3
.forceSimulation()
.force("x", d3.forceX(400))
.force("y", d3.forceY(300))

d3.forceX and d3.forceY create a 1-dimensional force towards the given coordinate. This force acts on every node in your graph and you can't change that. I tried.

If you want a multi-focal graph like in my tweet, you'll need to overlay multiple ForceGraph components. Each with its own centering force.

Swizec Teller writing a secret book avatarSwizec Teller writing a secret book@Swizec
this was fun to build

and by fun I mean that multi-focal force graphs in React and D3 almost broke me 😅

time to expand , looks like we got a new chapter for the cookbook!
Tweet media

A neat trick would be to define the centering coordinates with a mouse click. I'll let you play with that, you can get inspiration for correctly detecting mouse position from my Free-hand mouse drawing with D3v6 and React Hooks article.

Add a many-body force to repel or attract nodes

A many-body force lets nodes push or pull against each other.

You can think of this force as a "charge" between electrons.

const simulation = d3
.forceSimulation()
.force("x", d3.forceX(400))
.force("y", d3.forceY(300))
.force("charge", d3.forceManyBody().strength(2))

A positive value makes nodes attract, negative pushes them apart. The bigger the value the stronger the force.

Try the slider to see what happens :)

Playing with the slider to adjust manyBodyForce strength

We wrapped the initial node generation in a useMemo to avoid re-creating the nodes when you change the slider. That way d3.forceSimulation() can look stable through changes.

const nodes = useMemo(
() =>
d3.range(50).map((n) => {
return { id: n, r: 5 }
}),
[]
)

Add collisions to set smallest distance between nodes

A high charge looked weird and the nodes eventually overlapped into a single circle. That's not good.

We add a collision force to ensure that can't happen.

No matter how hard you squeeze, the nodes won't overlap 💪

Nodes can't overlap

const simulation = d3
.forceSimulation()
.force("x", d3.forceX(400))
.force("y", d3.forceY(300))
.force("charge", d3.forceManyBody().strength(charge))
.force("collision", d3.forceCollide(5))

d3.forceCollide takes an argument that specifies collision radius. We set it at 5 because that's how big our nodes are. You can pass a function to adjust this radius for individual nodes.

d3.forceCollide((node) => node.r)

That lets you support different sizes, enforce a gap, or allow some overlap.

Links work similarly. You'll need a list of links between nodes, like this:

{
source: node.id,
target: node.id
}

Pass it into your simulation with .links() and create a link force using force('links', d3.forceLink()). Usually this is an attracting force but it doesn't have to be! Go wild

Rendering your links is similar to rendering your nodes: Iterate through the links, render an element. Lines are a popular choice.

Don't forget to update your animation tick to re-render links.

// update state on every frame
simulation.on("tick", () => {
setAnimatedNodes([...simulation.nodes()])
setAnimatedLinks([...simulation.links()])
})

React ensures both updates happen in the same frame.

Give it a shot and lemme know if that doesn't work. Exercise for the reader and all that 😛

Cheers,
~Swizec

About the Author

Hi, I’m Swizec Teller. I help coders become software engineers.

Story time 👇

React+D3 started as a bet in April 2015. A friend wanted to learn React and challenged me to publish a book. A month later React+D3 launched with 79 pages of hard earned knowledge.

In April 2016 it became React+D3 ES6. 117 pages and growing beyond a single big project it was a huge success. I kept going, started live streaming, and publishing videos on YouTube.

In 2017, after 10 months of work, React + D3v4 became the best book I'd ever written. At 249 pages, many examples, and code to play with it was designed like a step-by-step course. But I felt something was missing.

So in late 2018 I rebuilt the entire thing as React for Data Visualization — a proper video course. Designed for busy people with real lives like you. Over 8 hours of video material, split into chunks no longer than 5 minutes, a bunch of new chapters, and techniques I discovered along the way.

React for Data Visualization is the best way to learn how to build scalable dataviz components your whole team can understand.

Some of my work has been featured in 👇

Created by Swizec with ❤️