React Components for D3 Charts

sen
Level Up Coding
Published in
5 min readMay 5, 2021

--

Photo by Isaac Smith on Unsplash

D3.js is a great tool for producing interactive visuals in your browser. It can also be a real pain in the ass to deal with. In an attempt to relieve some of this pain and preserve some sanity, I recently began refactoring some of the charts that I’d previously written with D3 — now using React functional components.

Packaging D3 into reusable components

The great thing about marrying D3 and React is that you essentially bundle up your visual into a callable HTML-style tag. You can pass in parameters to allow for easy customisation and updating, and any data-wrangling that can’t be carried out on the server-side can be carried out at the top level. Here’s a simple scatter chart, written in D3:

A basic scatter plot

And here’s how I draw that:

import React, { useState, useEffect } from “react”;
import ScatterChart from “./ScatterChart”;
export default function App(props) {
const [data, setData] = useState([]);
useEffect(() => {
setData([{‘a’:1,’b’:2},
{‘a’:4,’b’:1},
{‘a’:2,’b’:3},
{‘a’:1,’b’:3}])
},[])
if (!data) {
return null
}
return (
<div style={{height:’50%’,width:’50%’,marginTop:10,display:’inline-block’}}>
<ScatterChart data={data} xVal={‘a’} yVal={‘b’}/>
</div>
)
}

How nice is that? Slot it into a div for placement / sizing purposes and pass in any variables as parameters. No mess. Sweet.

In this example, I’m hardcoding a sample dataset in the useEffect() hook. Normally, I would fetch this data from my backend and hand it down to the ScatterChart component. If I want to have frontend filter options then I can deal with data changes through State. We need to wait until setData has actually returned the data before first rendering our component, hence returning null if our data is empty.

Writing D3 chart components

Let’s take a look at how it’s constructed. If you want to play along, you can use the ‘npx create-react-app’ command from your terminal to generate a playground environment. Add d3 to your project folder with ‘npm i d3’ and copy the above into your ‘App.js’ file. Add a new file to the ‘src’ directory named ‘ScatterChart.js’.

Begin by adding some boilerplate code:

import * as d3 from ‘d3’;import React, { useRef, useEffect } from ‘react’;export default function ScatterChart(props) {
const ref = useRef()
const height = 460,
width = 460
return (
<svg
viewBox={“0 0 “ + height + “ “ + width}
ref={ref}
/>
)
}

Here, we’ve defined and exported our functional component, ScatterChart, and defined some initial parameters (height, width and ref). The useRef() React hook provides a reference between our D3 code and the rendered SVG that we return. The snippets that follow will fit in sequence between the defined parameters and the return statement.

We need to grab our svg reference and set some initial parameters. To do that, add a useEffect hook.

useEffect(() => {
const svgElement = d3.select(ref.current)
.append(“g”)
.classed(“svgElement”, true)
.attr(“height”, height)
.attr(“width”, width)
},[])

If our data never changed, the rest of the chart could be drawn from within the same hook. I like to give myself the option to be able to update / filter the dataset without having to rewrite my component, hence I use a second useEffect hook to draw the rest of my chart. I need to grab the element I referenced in the previous hook, then set a few more parameters. Notice that we’re now referencing our props.

useEffect(() => {
const svgElement = d3.select(ref.current).select(“g.svgElement”)
var margin = { top: 10, right: 10, bottom: 10, left: 30 };
var xValMax = d3.max(props.data,function(d){return (d[props.xVal])})*1.1
var yValMax = d3.max(props.data,function(d){return (d[props.yVal])})*1.1
},[props.data])

Any parameters passed on to our component from the parent (in this case “App”, as defined in App.js) can be accessed through “props.parameterName”. This use of props makes React components very handy for storing D3 visuals. Firstly, I can pass in my data. This means that the parent component can fetch, modify and distribute the dataset to multiple child components. Secondly, any properties that I might want to change can be set as variables and altered by passing in parameters from the parent. In this example, i’m only using xVal and yVal (the attributes which will be measured on the x- and y- axes respectively). I could also pass in the text color, circle color, size, etc… We’ll get to that later.

Next, I want to define my axes.

//define x axis
var xScale = d3.scaleLinear()
.domain([0,scatterDomMax])
.range([margin.left, 400]);
var xAxis = svgElement.append(“g”)
.call(d3.axisBottom(xScale))
.attr(“class”,”x-axis”)
.attr(“transform”, “translate(0,”+(400)+“)”);
//x label
svgElement.append(“text”)
.attr(“class”,”scatterLabels”)
.style(“text-anchor”, “end”)
.attr(“x”, 405)
.attr(“y”, 395)
.style(“font-size”,”24px”)
.style(“fill”,”black”)
.text(props.xVal);
//define y axis
var yScale = d3.scaleLinear()
.domain([0,scatterDomMax])
.range([400,0])
var yAxis = svgElement.append(“g”)
.call(d3.axisLeft(yScale))
.attr(“class”,”y-axis”)
.attr(“transform”,“translate(“+ margin.left +“, 0)”)
//y label
svgElement.append(“text”)
.attr(“class”,”scatterLabels”)
.attr(“transform”, “rotate(-90)”)
.attr(“y”, margin.right + 18)
.attr(“dy”, “1em”)
.style(“text-anchor”, “end”)
.style(“font-size”,”24px”)
.style(“fill”,”black”)
.text(props.yVal);

Finally, we get to the juicy part; the update cycle. Using d3.join() we can deal with changes in our data relatively easily.

svgElement.selectAll(“circle”)
.data(props.data, d => d)
.join(enter => (
enter.append(“circle”)
.attr(“cx”, function (d) { return xScale(d[props.xVal]); })
.attr(“cy”, function (d) { return yScale(d[props.yVal]); })
.attr(“r”, function(d) {return 6})
.attr(“class”,”circles”)
.style(“stroke”, “red”)
.style(“stroke-width”, “2px”)
.style(“fill”, “green”)
.call(enter => (
enter.transition().duration(800)
))
),
update => (
update
.call(update => (
update.transition().duration(800)
))
),
exit => (
exit
.call(exit => {
exit.transition().duration(400)
.style(“opacity”,0)
.remove()
})
)
)

Here, we’re grabbing any circles attached to our svg element (which will be 0 on the first pass) and attaching the data passed down from the parent. The enter function will append a circle to the svg for each point in the dataset, adding a few styles (which can be parameterised) before calling a smooth transition which will play as the webpage loads. If data points are added, they are passed through this same enter function. Updated data points are transitioned via the update function, while data points that no longer exist are passed through the exit function and transitioned out of the visual.

Replacing props with parameters

Our initial ScatterChart function took in the props argument, which allows us to pass in any number of parameters to be access via props.parameterName. The thing is, I want to be able to alter styles without altering the component code. I need to define default parameters, such that I can optionally play around with my stylings when calling this component from ‘App.js’.

To do that is simple — we replace props with default values for any parameters we want to be able to change, and simply pass those variables to the relevant points in our component. Here’s an example wherein we pass default height / width parameters:

export default function ScatterChart({
height = 460,
width = 460
})
return (
<svg
viewBox={"0 0 " + height + " " + width}
ref={ref}
/>
)
}

Now, if we call <ScatterChart /> from ‘App.js’, the height and width will default to 460. If we call, for example, <ScatterChart height=500 width=500 /> then the height and width will each be set to 500. This is perfect for playing around with different colours / styles.

And there you have it. In the future i’ll be posting new D3 components to the site and to my git account. So look out for those if you’re interested.

Until then —

--

--