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.

LineChart.js
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)
      .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;

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 }) => {
  // 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" />