Building D3 Components with React: Part 1
Introduction
D3 is a powerful library for creating visualizations with JavaScript. While it allows a high-level of customizations, it can be challenging to create isolated, declarative components. Thankfully, this is something React does really well. While there are libraries for integrating the two, I’ve found creating a custom integration to work best for my purposes. We’ll walk through a basic example of how this integration works as well as some lessons learned along the way.
Up & Running
NOTE: While we’ll be walking through the details of this implementation, the following assumes you have some basic knowledge of React (specifically the component lifecycle) and some knowledge of D3. It’s also important to note that we’re using D3 4.x, which is a little different than 3.x.
We’ll be building basic progress arc. We’ll be using the create-react-app CLI, but I’ve added an example repo here. If you don’t have create-react-app already, you’ll need to install it with:
npm install -g create-react-app
create-react-app react-d3-example
cd react-d3-example
npm install --save d3
Now run npm start. If you see this in the browser, you’re good to go!
Setting our Context
First let’s add our D3 component:
touch src/ProgressArc.js
This is what it will look like:
import React, { Component } from 'react';
import * as d3 from "d3";
class ProgressArc extends Component {
render() {
return (
)
}
}
export default ProgressArc;
Let’s empty the contents of src/App.js
and call our ProgressArc.
import React, { Component } from 'react';
import ProgressArc from './ProgressArc';
class App extends Component {
render() {
return (
);
}
}
export default App;
Great! Now we’ll add our SVG context to src/ProgressArc.js
.
...
class ProgressArc extends Component {
componentDidMount() {
this.setContext();
}
setContext() {
return d3.select(this.refs.arc).append('svg')
.attr('height', '300px')
.attr('width', '300px')
.attr('id', 'd3-arc')
.append('g')
.attr('transform', `translate(150, 150)`);
}
...
The context
here is the SVG canvas where we’ll be drawing our visualization. When the component mounts, we’re appending a SVG, setting the height and width, and centering the content. Eventually we’ll make height
, width
, and id
dynamic props to be passed into the component.
Setting our Background
...
class ProgressArc extends Component {
componentDidMount() {
const context = this.setContext();
this.setBackground(context);
}
...
setBackground(context) {
return context.append('path')
.datum({ endAngle: this.tau })
.style('fill', '#e6e6e6')
.attr('d', this.arc());
}
tau = Math.PI * 2;
arc() {
return d3.arc()
.innerRadius(100)
.outerRadius(110)
.startAngle(0)
}
...
There’s quite a bit going on here, so let’s break it down. After we create our context
, we’re appending out background on top of it. Specifically, we’re appending a path
which we can shape and style. The datum
attribute is telling this path where to end, which we’re defining as tau
.
NOTE: We’re defining tau
as 2π here. D3 uses radians to measure arc length, which is fine, except most datasets don’t come in terms of radians, and to keep things simple, we’ll use tau
as a way to quickly convert radians to percent. All you really need to know about this is that the circumference of any circle is equal to 2 x π. We can then multiply tau
by a percentage and get a visualization that matches our expectations. (If I pass in 0.50, I should expect to see the path be a semi-circle.)
Since we want our background to be a full circle, we’ll set it to tau
and fill it with a light gray. This path will follow the path we are defining inthis.arc()
.
this.arc()
is returning a D3 arc that we are telling to start at 0
(the top, center of the circle) and has an inner-radius of 100px and an outer-radius or 110px. All of these properties will be things we can set as dynamic properties later when we call the component. If everything went well, you should see something like this when the view re-renders.
Setting Our Foreground
Now that we’ve created our background, we can add our foreground, which will display the percentage of our progress. Similar tosetBackground()
, we’ll call the function in componentDidMount()
and write the function below.
...
componentDidMount() {
const context = this.setContext();
this.setBackground(context);
this.setForeground(context);
}
...
setForeground(context) {
return context.append('path')
.datum({ endAngle: this.tau * 0.3 })
.style('fill', '#00ff00')
.attr('d', this.arc());
}
We’re setting the end angle for the foreground path to be 30% of the circumference and have a green fill
. If everything went well, it should look like this:
Adding Dynamic Props
Now that we have the basic setup, let’s refactor a bit to make these properties dynamic. First we’ll declare our props in src/App.js
.
...
class App extends Component {
render() {
return (
);
}
}
...
Let’s update ProgressArc to use props. We’ll also add propTypes
and displayName
as a general best practice.
...
class ProgressArc extends Component {
displayName: 'ProgressArc';
propTypes: {
id: PropTypes.string,
height: PropTypes.number,
width: PropTypes.number,
innerRadius: PropTypes.number,
outerRadius: PropTypes.number,
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
percentComplete: PropTypes.number
}
componentDidMount() {
const context = this.setContext();
this.setBackground(context);
this.setForeground(context);
}
setContext() {
const { height, width, id} = this.props;
return d3.select(this.refs.arc).append('svg')
.attr('height', height)
.attr('width', width)
.attr('id', id)
.append('g')
.attr('transform', `translate(${height / 2}, ${width / 2})`);
}
setBackground(context) {
return context.append('path')
.datum({ endAngle: this.tau })
.style('fill', this.props.backgroundColor)
.attr('d', this.arc());
}
setForeground(context) {
return context.append('path')
.datum({ endAngle: this.tau * this.props.percentComplete })
.style('fill', this.props.foregroundColor)
.attr('d', this.arc());
}
tau = Math.PI * 2;
arc() {
return d3.arc()
.innerRadius(this.props.innerRadius)
.outerRadius(this.props.outerRadius)
.startAngle(0)
}
...
Great! We’ve created a declarative, isolated component for D3! We (or another developer) could easily call this component somewhere else, pass in the desired properties, and have confidence it would work consistently. But there is another aspect we should tie up first. We should also create a way for this component to respond to updates.
Adding Updates
React provides a powerful API for updating components to reflect state changes via the VirtualDOM. Let’s add a toggle button to update the ProgressArc percentComplete
prop in src/App.js
.
class App extends Component {
constructor(props) {
super(props);
this.state = {percentComplete: 0.3};
this.togglePercent = this.togglePercent.bind(this);
}
togglePercent() {
const percentage = this.state.percentComplete === 0.3 ? 0.7 : 0.3;
this.setState({percentComplete: percentage});
}
render() {
console.log(this.state.percentComplete);
return (
);
}
}
There’s a bit going on here. We’ve added a toggle button that calls togglePercent()
to toggle the state from 0.3
to 0.7
percent when clicked. We’ve also added an ES6 constructor to set the initial state for the App component and a console.log()
in render()
as a sanity check to ensure the state is updating. However, after the view re-renders, you may notice an issue when the button is clicked. While we can see the state update in the JS console, the ProgressArc does not. Why?
This is where we get into some lessons learned. While React is updating the state and ProgressArc component as we would expect, the SVG does not reflect that change. This is because SVG’s don’t respond to updates. So we have to remove the initial SVG and re-draw a new one. To do this, we’ll need to modify how the ProgressArc handles updates.
First we’re going to roll up all of our functionality in componentDidMount()
into a function called drawArc()
.
class ProgressArc extends Component {
...
componentDidMount() {
this.drawArc();
}
drawArc() {
const context = this.setContext();
this.setBackground(context);
this.setForeground(context);
}
...
Now we’ll add the functions componentDidUpdate()
and redrawArc()
to handle updates to the percentComplete
prop.
...
componentDidUpdate() {
this.redrawArc();
}
drawArc() {
...
}
redrawArc() {
const context = d3.select(`#${this.props.id}`);
context.remove();
this.drawArc();
}
...
We’re selecting the SVG context by the id
prop we set earlier, removing it from the DOM, and calling drawArc()
to redraw with the new data available. Thankfully, D3 does this so quickly, it will look like the same context is simply updating based on the new data. If all is well, we should see something like this:
Cool! Now we have an isolated, declarative D3 component that responds to updates. We’ve abstracted the internals of how D3 works and created a visualization that can easily be used by anyone!
Part 2: Adding Animation
If you’d like to take this a step further and add animation to this transition, head over to Part 2!