D3 with React
Intro
Looking into data visualization it seems clear that the library with the greatest capability and flexibility is D3, which has over a thousand methods. It goes down to such base components that it does not even consider itself to be a data visualization library, but instead provides "efficient manipulation of documents based on data". Basically it binds data to DOM objects and gives you lots of ways to manipulate them, which is used by a lot of people to manipulate SVG graphics, and thus data visualization. It appears to have a bit of a learning curve but we are up for it. I would rather learn something new than get bottle-necked later using a prebuilt library.
Compatibility with React
There is a fundamental compatibility issue between React and D3. Because React creates a Virtual DOM, and D3 works by creating and manipulating objects in the actual DOM, we have to find a way to get D3 working inside the Virtual DOM. This problem has been solved a number of different ways and we are going to look at a few of them here.
You would think that there would just be a package that would handle this. There is a package called react-d3-library that purported to let you use stock D3 code in React with just a few adjustments. However this package is no longer maintained and the last commit was 4 years ago. So this does not seem like the route to go down.
Here are some of the other methods that people have written about.
Amelia Wattenberger has written an excellent article React + D3 where she basically advocates that you shouldn't be using the D3 methods that render the SVG graphics, but to render them all manually using JSX and only use D3 methods that do math like calculating scale. On the one hand she may have a point here, but on the other hand she is basically throwing away the majority of the D3 methods and recreating them which is a huge duplication of effort. It's almost as if her answer to the question of how to use D3 with React is "You don't".
The other sources that I have found seem to be on more of a similar page. They advocate a combination of useRef
to direct D3 to an SVG and useEffect
to manipulate it.
griddynamics: Using D3.js with React.js: An 8-step comprehensive manual
Pluralsight: Using D3.js Inside a React App
LogRocket: Using D3.js v6 with React
We will be taking that route.
Simplest Possible implementation
Let's just start by getting an SVG on the page, with a container and one circle. We want everything to be contained in a component, and later we will feed data into this component as a prop.
import React, { useRef, useEffect } from "react";
import * as d3 from "d3";
const LineChart = (props) => {
const svgRef = useRef(null);
useEffect(() => {
// D3 Code
// Dimensions
let dimensions = {
width: 1000,
height: 500,
margins: 50,
};
dimensions.containerWidth = dimensions.width - dimensions.margins * 2;
dimensions.containerHeight = dimensions.height - dimensions.margins * 2;
// SELECTIONS
const svg = d3
.select(svgRef.current)
.classed("line-chart", true)
.attr("width", dimensions.width)
.attr("height", dimensions.height);
const container = svg
.append("g")
.classed("container", true)
.attr("transform", `translate(${dimensions.margins}, ${dimensions.margins})`);
// Draw Circle
container.append("circle").attr("r", 25);
}, [props.Data, svgRef.current]); // redraw chart if data changes
return <svg ref={svgRef} />;
};
export default LineChart;
and then we can call this component anywhere
import React, { Component } from "react";
// Components
import LineChart from './LineChart'
let data = [
{ date: 20220101, impressions: 100 },
{ date: 20220102, impressions: 120 },
// ... truncated but you get it
];
class Test extends Component {
render() {
return (
<>
<LineChart Data={data} />
</>
);
}
}
export default Test;
Why do we even need to use useRef
at all? This article explains the reasoning behind that well:
Medium: Simple D3 with React Hooks
From here it's just a matter of adding back in all your graph functionality. There are a couple little caveats though.
Handling Component Lifecycle
One of the things I have noticed is that every time the graph component re-renders, the way things are set up now is that a new container group is generated every time. We have not separated the portions of our code that generate the elements, and the portions that modify the elements into lifecycle controlled portions. So everytime the component re-renders everything that is appended to the SVG is just appended again with another copy.
There is a simple solution and a complex solution.
The simple solution would be to just wipe all the existing elements everytime the component loads before content is drawn like this.
// Selections
const svg = d3
.select(svgRef.current)
.classed("line-chart", true)
.attr("width", dimensions.width)
.attr("height", dimensions.height);
// clear all previous content on refresh
const everything = svg.selectAll("*");
everything.remove();
const container = svg
.append("g")
.classed("container", true)
.attr("transform", `translate(${dimensions.margins}, ${dimensions.margins})`);
That surely solves the problem.
Of course if you wanted to be all Reacty about it you could separate anything that modifies existing elements into the callback function for the useEffect
hook. Or you could use a class component with lifecycle methods. I'm sure that would be slightly more performant, but the troubleshooting involved in keeping all those events separated like that seems tedious.
Multiple Refs
It is entirely possible that you will need to create multiple useRef references. For example a common design pattern in D3 is to create a tooltip, which exists as a separate element on top of the SVG canvas. So that we might have a component like this
import React, { useRef, useEffect } from "react";
import * as d3 from "d3";
const LineChart = ({ Data, data_type }) => {
// Element References
const svgRef = useRef(null);
const tooltipRef = useRef(null);
useEffect(() => {
// D3 Code
// Data
const dataset = Data;
console.log(dataset);
// Accessors
const parseDate = d3.timeParse("%Y%m%d");
let xAccessor;
let yAccessor;
// variable accessor depending on datatype
switch (data_type) {
case "campaign_conversions":
xAccessor = (d) => parseDate(d.date);
yAccessor = (d) => d.Conversions;
break;
case "campaign_impressions":
xAccessor = (d) => parseDate(d.date);
yAccessor = (d) => d.Impressions;
break;
default:
// n/a
}
// Dimensions
let dimensions = {
width: 1000,
height: 500,
margins: 50,
};
dimensions.containerWidth = dimensions.width - dimensions.margins * 2;
dimensions.containerHeight = dimensions.height - dimensions.margins * 2;
// Selections
const svg = d3
.select(svgRef.current) // highlight-line
.classed("line-chart-svg", true)
.attr("width", dimensions.width)
.attr("height", dimensions.height);
// clear all previous content on refresh
const everything = svg.selectAll("*");
everything.remove();
const container = svg
.append("g")
.classed("container", true)
.attr("transform", `translate(${dimensions.margins}, ${dimensions.margins})`);
const tooltip = d3.select(tooltipRef.current); // highlight-line
const tooltipDot = container
.append("circle")
.classed("tool-tip-dot", true)
.attr("r", 5)
.attr("fill", "#fc8781")
.attr("stroke", "black")
.attr("stroke-width", 2)
.style("opacity", 0)
.style("pointer-events", "none");
// Scales
const yScale = d3
.scaleLinear()
.domain([0, d3.max(dataset, yAccessor)])
.range([dimensions.containerHeight, 0])
.nice();
const xScale = d3.scaleTime().domain(d3.extent(dataset, xAccessor)).range([0, dimensions.containerWidth]);
// Line Generator
const lineGenerator = d3
.line()
.x((d) => xScale(xAccessor(d)))
.y((d) => yScale(yAccessor(d)));
// Draw Line
container
.append("path")
.datum(dataset)
.attr("d", lineGenerator)
.attr("fill", "none")
.attr("stroke", "#30475e")
.attr("stroke-width", 2);
// Axis
const yAxis = d3.axisLeft(yScale).tickFormat((d) => `${d}`);
container.append("g").classed("yAxis", true).call(yAxis);
const xAxis = d3.axisBottom(xScale);
container
.append("g")
.classed("xAxis", true)
.style("transform", `translateY(${dimensions.containerHeight}px)`)
.call(xAxis);
// Tooltip
container
.append("rect")
.classed("mouse-tracker", true)
.attr("width", dimensions.containerWidth)
.attr("height", dimensions.containerHeight)
.style("opacity", 0)
.on("touchmouse mousemove", function (event) {
const mousePos = d3.pointer(event, this);
// x coordinate stored in mousePos index 0
const date = xScale.invert(mousePos[0]);
// Custom Bisector - left, center, right
const dateBisector = d3.bisector(xAccessor).center;
const bisectionIndex = dateBisector(dataset, date);
//console.log(bisectionIndex);
// math.max prevents negative index reference error
const hoveredIndexData = dataset[Math.max(0, bisectionIndex)];
// Update Image
tooltipDot
.style("opacity", 1)
.attr("cx", xScale(xAccessor(hoveredIndexData)))
.attr("cy", yScale(yAccessor(hoveredIndexData)))
.raise();
tooltip
.style("display", "block")
.style("top", `${yScale(yAccessor(hoveredIndexData)) - 50}px`)
.style("left", `${xScale(xAccessor(hoveredIndexData))}px`);
tooltip.select(".data").text(`${yAccessor(hoveredIndexData)}`);
const dateFormatter = d3.timeFormat("%B %-d, %Y");
tooltip.select(".date").text(`${dateFormatter(xAccessor(hoveredIndexData))}`);
})
.on("mouseleave", function () {
tooltipDot.style("opacity", 0);
tooltip.style("display", "none");
});
}, [Data, data_type]); // redraw chart if data changes
return (
<div className="line-chart">
<svg ref={svgRef} />
<div ref={tooltipRef} class="lc-tooltip">
<div className="data"></div>
<div className="date"></div>
</div>
</div>
);
};
export default LineChart;
This is necessary because if you fail to do this you will run into issues when you have multiple instances of a LineChart rendered simultaneously. You cannot reference the tooltip using an ID or Class.
Data and Accessors as Props
In order to make the LineChart component truly re-usable we will need to be able to change our Data, and therefore the accessors depending on the data. It is quite obvious that the data will be passed in as a prop. We could also pass in the accessors as a prop, however that would be repetitive and cluttered. It is much more organized to create "Data Types" and then create a switch case that defines our accessors.
import React, { useRef, useEffect } from "react";
import * as d3 from "d3";
const LineChart = ({ Data, data_type }) => { // highlight-line
// Element References
const svgRef = useRef(null);
const tooltipRef = useRef(null);
useEffect(() => {
// D3 Code
// Data
const dataset = Data;
console.log(dataset);
// Accessors
const parseDate = d3.timeParse("%Y%m%d");
let xAccessor;
let yAccessor;
// variable accessor depending on datatype
switch (data_type) {
case "campaign_conversions":
xAccessor = (d) => parseDate(d.date);
yAccessor = (d) => d.Conversions;
break;
case "campaign_impressions":
xAccessor = (d) => parseDate(d.date);
yAccessor = (d) => d.Impressions;
break;
default:
// n/a
}
// Dimensions
let dimensions = {
width: 1000,
height: 500,
margins: 50,
};
dimensions.containerWidth = dimensions.width - dimensions.margins * 2;
dimensions.containerHeight = dimensions.height - dimensions.margins * 2;
// Selections
const svg = d3
.select(svgRef.current)
.classed("line-chart-svg", true)
.attr("width", dimensions.width)
.attr("height", dimensions.height);
// clear all previous content on refresh
const everything = svg.selectAll("*");
everything.remove();
const container = svg
.append("g")
.classed("container", true)
.attr("transform", `translate(${dimensions.margins}, ${dimensions.margins})`);
const tooltip = d3.select(tooltipRef.current);
const tooltipDot = container
.append("circle")
.classed("tool-tip-dot", true)
.attr("r", 5)
.attr("fill", "#fc8781")
.attr("stroke", "black")
.attr("stroke-width", 2)
.style("opacity", 0)
.style("pointer-events", "none");
// Scales
const yScale = d3
.scaleLinear()
.domain([0, d3.max(dataset, yAccessor)])
.range([dimensions.containerHeight, 0])
.nice();
const xScale = d3.scaleTime().domain(d3.extent(dataset, xAccessor)).range([0, dimensions.containerWidth]);
// Line Generator
const lineGenerator = d3
.line()
.x((d) => xScale(xAccessor(d)))
.y((d) => yScale(yAccessor(d)));
// Draw Line
container
.append("path")
.datum(dataset)
.attr("d", lineGenerator)
.attr("fill", "none")
.attr("stroke", "#30475e")
.attr("stroke-width", 2);
// Axis
const yAxis = d3.axisLeft(yScale).tickFormat((d) => `${d}`);
container.append("g").classed("yAxis", true).call(yAxis);
const xAxis = d3.axisBottom(xScale);
container
.append("g")
.classed("xAxis", true)
.style("transform", `translateY(${dimensions.containerHeight}px)`)
.call(xAxis);
// Tooltip
container
.append("rect")
.classed("mouse-tracker", true)
.attr("width", dimensions.containerWidth)
.attr("height", dimensions.containerHeight)
.style("opacity", 0)
.on("touchmouse mousemove", function (event) {
const mousePos = d3.pointer(event, this);
// x coordinate stored in mousePos index 0
const date = xScale.invert(mousePos[0]);
// Custom Bisector - left, center, right
const dateBisector = d3.bisector(xAccessor).center;
const bisectionIndex = dateBisector(dataset, date);
//console.log(bisectionIndex);
// math.max prevents negative index reference error
const hoveredIndexData = dataset[Math.max(0, bisectionIndex)];
// Update Image
tooltipDot
.style("opacity", 1)
.attr("cx", xScale(xAccessor(hoveredIndexData)))
.attr("cy", yScale(yAccessor(hoveredIndexData)))
.raise();
tooltip
.style("display", "block")
.style("top", `${yScale(yAccessor(hoveredIndexData)) - 50}px`)
.style("left", `${xScale(xAccessor(hoveredIndexData))}px`);
tooltip.select(".data").text(`${yAccessor(hoveredIndexData)}`);
const dateFormatter = d3.timeFormat("%B %-d, %Y");
tooltip.select(".date").text(`${dateFormatter(xAccessor(hoveredIndexData))}`);
})
.on("mouseleave", function () {
tooltipDot.style("opacity", 0);
tooltip.style("display", "none");
});
}, [Data, data_type]); // redraw chart if data changes
return (
<div className="line-chart">
<svg ref={svgRef} />
<div ref={tooltipRef} class="lc-tooltip">
<div className="data"></div>
<div className="date"></div>
</div>
</div>
);
};
export default LineChart;
You could extend this method to scales, Axis labels, etc etc. Then you will have an easy and consistent graph component for multiple datatypes, and you need only supply the data and the data type to the component when you call it.
<LineChart Data={data} data_type="campaign_impressions" />
Comments
Recent Work
Basalt
basalt.softwareFree desktop AI Chat client, designed for developers and businesses. Unlocks advanced model settings only available in the API. Includes quality of life features like custom syntax highlighting.
BidBear
bidbear.ioBidbear is a report automation tool. It downloads Amazon Seller and Advertising reports, daily, to a private database. It then merges and formats the data into beautiful, on demand, exportable performance reports.