D3.js with React.js: An 8-step comprehensive manual

D3.js with React.js: An 8-step comprehensive manual

Apr 01, 2021 • 17 min read

In this manual, we share our experience of using D3.js in React to build custom, scalable, and engaging charts via creating reusable components. We recommend this article for:

  • React developers ready to start with D3.js
  • Web developers engaged in implementing data-driven UI
  • Anyone interested in learning how to build projects with D3.js
  • Anyone who is looking for react d3 tutorial

In the next sections, we’d like to discuss how to start working with D3.js, why you should use this library, and how to integrate it with React.

Introduction

React and D3.js are JavaScript libraries that enable developers to build scalable, customizable, and appealing data-driven UI. We used these libraries to build one of the Grid Dynamics’ R&D projects called Trading Robo-Advisor. It is a software-based product designed for automated investment and asset management that is done largely through algorithms with minimal to zero human interactions. The idea was to create both - the algorithms to calculate users’ portfolios and automatically buy or sell stocks from the market, and the UI application (for Web and iOS) to show all this data to the user.

In this article, we share how we implemented charts in our Web application using both React and D3.js.

Why you should use D3.js

Large datasets visualization is as valuable as implementing modern interactive Web pages for JavaScript developers. D3.js library enables developers to build informative, comprehensive, and scalable visual data representations via shapes, lines, interpolation, etc. These features make D3.js a solid solution if you need to construct charts with unique style or animations, a large set of options, and custom handlers that the ready-to-use chart library can't provide.

Using D3.js with React

In 8 steps, we developed a multiline chart from scratch using D3.js and React libraries. Find all details in the sections below.

Step 1: Check D3.js API and ready examples

We checked the D3.js API and didn’t find any examples there. So, we followed the provided links to the third-party examples. To get started, we could, as well, just search what we needed on the Internet. There are tons of solutions from other developers ready for reuse and extension.

Step 2: Determine responsibilities

When checking the documentation, we saw that D3.js had a strong set for manipulation with data. There were a lot of computing functions and methods to visualize data. As D3.js can calculate and modify anything for us, all we should do is visualize it. Since React also operates with DOM, we should follow the way of separating the responsibility in DOM manipulation.

Our task was to find a way to let D3.js work based on math, analytics, animations, scaling, and React work based on rendering the view. Keeping it in mind, we decided to start with the simple and quick solution described below.

Step 3: Get things done using an all-in-one function approach

We started with creating an <svg /> element in our component, saving the reference to it, and then adding all needed chart elements using D3 after SVG was mounted to the DOM.

As an example, we drew a multiline chart. This chart showed the performance of an investment user portfolio for the selected period of time. Also, it allowed us to compare user portfolio performance with other indexes from the market.


/** App.js */
import React from "react";
import MultilineChart from "./views/MultilineChart";
import schc from "./SCHC.json";
import vcit from "./VCIT.json";
import portfolio from "./PORTFOLIO.json";
 
const portfolioData = { name: "Portfolio", color: "#ffffff", items: portfolio };
const schcData = { name: "SCHC", color: "#d53e4f", items: schc };
const vcitData = { name: "VCIT", color: "#5e4fa2", items: vcit };
const dimensions = {
  width: 600,
  height: 300,
  margin: { top: 30, right: 30, bottom: 30, left: 60 }
};
 
export default function App() {
  return (
    <div className="App">
      <MultilineChart
  data={[portfolioData, schcData, vcitData]}
  dimensions={dimensions}
      />

/** MultilineChart.js */
import React from "react";
import * as d3 from "d3";
 
const MultilineChart = ({ data, dimensions }) => {
  const svgRef = React.useRef(null);
  const { width, height, margin } = dimensions;
  const svgWidth = width + margin.left + margin.right;
  const svgHeight = height + margin.top + margin.bottom;
 
  React.useEffect(() => {
    const xScale = d3.scaleTime()
      .domain(d3.extent(data[0].items, (d) => d.date))
      .range([0, width]);
    const yScale = d3.scaleLinear()
      .domain([
        d3.min(data[0].items, (d) => d.value) - 50,
        d3.max(data[0].items, (d) => d.value) + 50
      ])
      .range([height, 0]);
    // Create root container where we will append all other chart elements
    const svgEl = d3.select(svgRef.current);
    svgEl.selectAll("*").remove(); // Clear svg content before adding new elements 
    const svg = svgEl
      .append("g")
      .attr("transform", `translate(${margin.left},${margin.top})`);
   // Add X grid lines with labels
   const xAxis = d3.axisBottom(xScale)
     .ticks(5)
     .tickSize(-height + margin.bottom);
   const xAxisGroup = svg.append("g")
     .attr("transform", `translate(0, ${height - margin.bottom})`)
     .call(xAxis);
   xAxisGroup.select(".domain").remove();
   xAxisGroup.selectAll("line").attr("stroke", "rgba(255, 255, 255, 0.2)");
   xAxisGroup.selectAll("text")
     .attr("opacity", 0.5)
     .attr("color", "white")
     .attr("font-size", "0.75rem");
   // Add Y grid lines with labels
   const yAxis = d3.axisLeft(yScale)
     .ticks(5)
     .tickSize(-width)
     .tickFormat((val) => `${val}%`);
   const yAxisGroup = svg.append("g").call(yAxis);
   yAxisGroup.select(".domain").remove();
   yAxisGroup.selectAll("line").attr("stroke", "rgba(255, 255, 255, 0.2)");
   yAxisGroup.selectAll("text")
     .attr("opacity", 0.5)
     .attr("color", "white")
     .attr("font-size", "0.75rem");
    // Draw the lines
    const line = d3.line()
      .x((d) => xScale(d.date))
      .y((d) => yScale(d.value));
    svg.selectAll(".line")
      .data(data)
      .enter()
      .append("path")
      .attr("fill", "none")
      .attr("stroke", (d) => d.color)
      .attr("stroke-width", 3)
      .attr("d", (d) => line(d.items));
  }, [data]); // Redraw chart if data changes
 
  return <svg ref={svgRef} width={svgWidth} height={svgHeight} />;
};
 
export default MultilineChart;

All react d3 examples from this tutorial you can find in codesandbox.

To see how the chart was rendered using this code, go to https://codesandbox.io/s/d3react-multiline-chart-version-1-simple-5nxwo



As a result, we created a chart to display the user's portfolio performance (white line) compared to the market’s stocks, marked with pink (SCHC) and purple (VCIT) colors.

Step 4: Dig into streams manipulation

The D3.js API provides excellent tooling for work with streams. We can easily select the relevant nodes downstream and manipulate their attributes or retrieve the necessary, filtered collection. See how we added some interaction—choosing the lines to be displayed—below:


/** App.js */
***
import Legend from "./views/Legend";
***
 
export default function App() {
  const [selectedItems, setSelectedItems] = React.useState([]);
  const legendData = [portfolioData, schcData, vcitData];
  const chartData = [
    portfolioData,
    ...[schcData, vcitData].filter((d) => selectedItems.includes(d.name))
  ];
  const onChangeSelection = (name) => {
    const newSelectedItems = selectedItems.includes(name)
? selectedItems.filter((item) => item !== name)
: [...selectedItems, name];
    setSelectedItems(newSelectedItems);
 };
 
 return (
   
); }

/** Legend.js */
import React from "react";
 
const Legend = ({ data, selectedItems, onChange }) => (
 <div className="legendContainer">
   {data.map((d) => (
     <div style={{ color: d.color }} key={d.name}>
       <label>
         {d.name !== "Portfolio" && (
           <input
             type="checkbox"
             checked={selectedItems.includes(d.name)}
             onChange={() => onChange(d.name)}
           />
         )}
         {d.name}
       </label>
     </div>
   ))}
 </div>
);
 
export default Legend;

Now, we allowed users to select a necessary market stock to compare it with their portfolio performance. To see how the chart was rendered using this code, go to https://codesandbox.io/s/d3react-multiline-chart-version-2-interaction-4uld2.


Step 5: Add animation

Modern user-interactive charts claimed to be built with smooth and appealing transitions. The D3.js API gives the opportunity to animate elements using transitions, interpolations, etc. Here is how we added some animation.


/** MuiltilineCharts.js */
***
const MultilineChart = ({ data, dimentions }) => {
  const svgRef = React.useRef(null);
  // to detect what line to animate we should store previous data state 
  const [prevItems, setPrevItems] = React.useState([]);
  ***
  React.useEffect(() => {
    ***
    const line = d3.line()
      .x((d) => xScale(d.date))
      .y((d) => yScale(d.value));
    const lines = svg.selectAll(".line")
      .data(data)
      .enter()
      .append("path")
      .attr("fill", "none")
      .attr("stroke", (d) => d.color)
      .attr("stroke-width", 3)
      .attr("d", (d) => line(d.items));
    // Use stroke-dashoffset for transition
    lines.each((d, i, nodes) => {
      const element = nodes[i];
      const length = element.getTotalLength();
      if (!prevItems.includes(d.name)) {
        d3.select(element)
          .attr("stroke-dasharray", `${length},${length}`)
          .attr("stroke-dashoffset", length)
          .transition()
          .duration(750)
          .ease(d3.easeLinear)
          .attr("stroke-dashoffset", 0);
      }
    });
    setPrevItems(data.map(({ name }) => name));
  }, [data, margin]);
 
  return <svg ref={svgRef} width={svgWidth} height={svgHeight} />;
};

To see how the chart was rendered using this code, go to https://codesandbox.io/s/d3react-multiline-chart-version-3-animation-o5y57

The chart provided in the article was built with SVG; the animation was added using D3.js via d3-transition and d3-selection on SVG nodes. D3.js helped with displaying and animating data with efficient performance. It was possible to use animation with both Canvas and SVG. The Trading Robo-Advisor project requested the user's interaction in charts and handling the user’s interactions with adding and deleting data for rendered charts. After carrying off these points, we decided to choose SVG to implement reusable components as the data visualization blocks.


Step 6: Separate functions — Working with more complex solutions

After adding new functionality to our chart, the useEffect() hook became bigger and made it difficult to figure out what line of code it was responsible for. Usually, at this stage, one function could be split to separate functions that we could dispatch. In some cases, it can even be possible to extract these functions to utils and use them later in other chart components.


/** utils.js */
import * as d3 from "d3";
 
export const getXScale = (data, width) => d3.scaleTime()
  .domain(d3.extent(data, (d) => d.date))
  .range([0, width]);
 
export const getYScale = (data, height, intention) => d3.scaleLinear()
  .domain([
    d3.min(data, (d) => d.value) - intention,
    d3.max(data, (d) => d.value) + intention
  ])
  .range([height, 0]);
 
const applyAxisStyles = (container) => {
  container.select(".domain").remove();
  container.selectAll("line").attr("stroke", "rgba(255, 255, 255, 0.2)");
  return container.selectAll("text")
    .attr("opacity", 0.5)
    .attr("color", "white")
    .attr("font-size", "0.75rem");
};
 
export const drawAxis = ({
  container, xScale, yScale, ticks, tickSize, tickFormat, transform
}) => {
  const scale = xScale || yScale;
  const axisType = xScale ? d3.axisBottom : d3.axisLeft;
  const axis = axisType(scale)
    .ticks(ticks)
    .tickSize(tickSize)
    .tickFormat(tickFormat);
  const axisGroup = container.append("g")
    .attr("class", "axis")
    .attr("transform", transform)
    .call(axis);
  return applyAxisStyles(axisGroup);
};
 
export const drawLine = ({ container, data, xScale, yScale }) => {
  const line = d3.line()
    .x((d) => xScale(d.date))
    .y((d) => yScale(d.value));
  return container.append("path")
    .datum(data)
    .attr("fill", "none")
    .attr("stroke", (d) => d.color)
    .attr("stroke-width", 3)
    .attr("d", (d) => line(d.items));
};
 
export const animateLine = ({ element }) => {
  const length = element.getTotalLength();
  return d3.select(element)
    .attr("stroke-dasharray", `${length},${length}`)
    .attr("stroke-dashoffset", length)
    .transition()
    .duration(750)
    .ease(d3.easeLinear)
    .attr("stroke-dashoffset", 0);
};

In our MultilineChart component, we had two useEffect() hooks. One hook was used for drawing axes with scale and dimension in dependencies as it may re-render when data or size has changed. This approach saved our performance from getting low and laggy. Another hook was used for drawing the lines with scale and data in dependencies. In both hooks, do not forget to remove old svg elements before adding new ones.


/** MultilineChart.js */
import React from "react";
import * as d3 from "d3";
import { getXScale, getYScale, drawAxis, drawLine, animateLine } from "../../utils";
 
const MultilineChart = ({ data = [], dimensions = {} }) => {
  ***
  const [portfolioData] = data;
  const xScale = React.useMemo(
    () => getXScale(portfolioData.items, width),
    [portfolioData, width]
  );
  const yScale = React.useMemo(
    () => getYScale(portfolioData.items, height, 50),
    [portfolioData, height]
  );
 
  React.useEffect(() => {
    const svg = d3.select(".container");
    svg.selectAll(".axis").remove();
    // Add X grid lines with labels
    drawAxis({
      xScale,
      container: svg,
      tickSize: -height + margin.bottom,
      ticks: 5,
      transform: `translate(0, ${height - margin.bottom})`
    });
    // Add Y grid lines with labels
    drawAxis({
      yScale,
      container: svg,
      tickSize: -width,
      ticks: 5,
      tickFormat: (val) => `${val}%`
    });
  }, [xScale, yScale, width, height, margin]);
 
  React.useEffect(() => {
    const svg = d3.select(".container");
    svg.selectAll("path").remove();
    // Draw the lines
    data.forEach((d) => {
      const line = drawLine({ container: svg, data: d, xScale, yScale });
      if (!prevItems.includes(d.name)) {
        animateLine({ element: line.node() });
      }
    });
    setPrevItems(data.map(({ name }) => name));
  }, [data, xScale, yScale]);
 
  return (
    <svg ref={svgRef} width={svgWidth} height={svgHeight}>
      {/* As g element is a container for axises and lines, */}
      {/* we moved it from hook to jsx directly */}
      <g className="container" transform={`translate(${margin.left},${margin.top})`}/>
    </svg>
  );
};
 
export default MultilineChart;

The chart looked much better, but it still didn’t come as natural for React developers. It was like adding a <div /> element to the DOM using React, and then including jQuery to the project and starting using it for DOM manipulation. It made sense to split our chart to separate components instead of separate methods, which were actually just manipulating mounted DOM elements.

Step 7: Separate components to be reusable

First, we looked at the elements tree that D3 constructed for us and thought about what we could add to the separate components.



Then, we splitted the elements into separate components — Axis returned a g element, and Line returned a path. Also we created a GridLine component and separated it from Axis, so we would have more control. For example, we could draw grid lines for 0, max, and min values, and title only for max and min values.


/** GridLine.js */
import React from "react";
import PropTypes from "prop-types";
import * as d3 from "d3";
 
const GridLine = ({
  type, scale, ticks, size, transform, disableAnimation, ...props
}) => {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const axisGenerator = type === "vertical" ? d3.axisBottom : d3.axisLeft;
    const axis = axisGenerator(scale).ticks(ticks).tickSize(-size);
 
    const gridGroup = d3.select(ref.current);
    if (disableAnimation) {
      gridGroup.call(axis);
    } else {
      gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
    }
    gridGroup.select(".domain").remove();
    gridGroup.selectAll("text").remove();
    gridGroup.selectAll("line").attr("stroke", "rgba(255, 255, 255, 0.1)");
  }, [scale, ticks, size, disableAnimation]);
 
  return <g ref={ref} transform={transform} {...props} />;
};
 
GridLine.propTypes = {
  type: PropTypes.oneOf(["vertical", "horizontal"]).isRequired
};
 
export default GridLine;


/** Axis.js */
import React from "react";
import PropTypes from "prop-types";
import * as d3 from "d3";
 
const Axis = ({
  type, scale, ticks, transform, tickFormat, disableAnimation, ...props
}) => {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const axisGenerator = type === "left" ? d3.axisLeft : d3.axisBottom;
    const axis = axisGenerator(scale).ticks(ticks).tickFormat(tickFormat);
    const axisGroup = d3.select(ref.current);
    if (disableAnimation) {
      axisGroup.call(axis);
    } else {
      axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
    }
    axisGroup.select(".domain").remove();
    axisGroup.selectAll("line").remove();
    axisGroup.selectAll("text")
      .attr("opacity", 0.5)
      .attr("color", "white")
      .attr("font-size", "0.75rem");
  }, [scale, ticks, tickFormat, disableAnimation]);
 
  return <g ref={ref} transform={transform} {...props} />;
};
 
Axis.propTypes = {
  type: PropTypes.oneOf(["left", "bottom"]).isRequired
};
 
export default Axis;



/** Line.js */
import React from "react";
import * as d3 from "d3";
 
const Line = ({
  xScale, yScale, color, data, animation, ...props
}) => {
  const ref = React.useRef(null);
  // Define different types of animation that we can use
  const animateLeft = React.useCallback(() => {
    const totalLength = ref.current.getTotalLength();
    d3.select(ref.current)
      .attr("opacity", 1)
      .attr("stroke-dasharray", `${totalLength},${totalLength}`)
      .attr("stroke-dashoffset", totalLength)
      .transition()
      .duration(750)
      .ease(d3.easeLinear)
      .attr("stroke-dashoffset", 0);
  }, []);
  const animateFadeIn = React.useCallback(() => {
    d3.select(ref.current)
      .transition()
      .duration(750)
      .ease(d3.easeLinear)
      .attr("opacity", 1);
  }, []);
  const noneAnimation = React.useCallback(() => {
    d3.select(ref.current).attr("opacity", 1);
  }, []);
 
  React.useEffect(() => {
    switch (animation) {
      case "left":
        animateLeft();
        break;
      case "fadeIn":
        animateFadeIn();
        break;
      case "none":
      default:
        noneAnimation();
        break;
    }
  }, [animateLeft, animateFadeIn, noneAnimation, animation]);
 
 // Recalculate line length if scale has changed
  React.useEffect(() => {
    if (animation === "left") {
      const totalLength = ref.current.getTotalLength();
      d3.select(ref.current).attr(
        "stroke-dasharray",
        `${totalLength},${totalLength}`
      );
    }
  }, [xScale, yScale, animation]);
 
  const line = d3.line()
    .x((d) => xScale(d.date))
    .y((d) => yScale(d.value));
 
  return (
    <path
      ref={ref}
      d={line(data)}
      stroke={color}
      strokeWidth={3}
      fill="none"
      opacity={0}
      {...props}
    />
  );
};
 
export default Line;

For the portfolio line we can also add gradient areas just to make our chart look more appealing.


/** Area.js */
import React from "react";
import * as d3 from "d3";
 
const Area = ({ xScale, yScale, color, data, disableAnimation, ...props }) => {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (disableAnimation) {
      d3.select(ref.current).attr("opacity", 1);
      return;
    }
    d3.select(ref.current).transition()
      .duration(750)
      .ease(d3.easeBackIn)
      .attr("opacity", 1);
  }, [disableAnimation]);
 
  const d = React.useMemo(() => {
    const area = d3.area()
      .x(({ date }) => xScale(date))
      .y1(({ value }) => yScale(value))
      .y0(() => yScale(yScale.domain()[0]));
    return area(data);
  }, [xScale, yScale, data]);
 
  return (
    <>
      <path ref={ref} d={d} fill={`url(#gradient-${color})`} opacity={0} {...props}/>
      <defs>
        <linearGradient id={`gradient-${color}`} x1="0%" x2="0%" y1="0%" y2="100%">
          <stop offset="0%" stopColor={color} stopOpacity={0.2} />
          <stop offset="100%" stopColor={color} stopOpacity={0} />
        </linearGradient>
      </defs>
    </>
  );
};
 
export default Area;

/** MultilineChart.js */
import React from "react";
import { Line, Axis, GridLine, Area } from "../components";
import useController from "./MultilineChart.controller";
 
const MultilineChart = ({ data = [], dimensions = {} }) => {
  const { width, height, margin = {} } = dimensions;
  const svgWidth = width + margin.left + margin.right;
  const svgHeight = height + margin.top + margin.bottom;
  const controller = useController({ data, width, height });
  const { yTickFormat, xScale, yScale, yScaleForAxis } = controller;
 
  return (
    <svg width={svgWidth} height={svgHeight}>
      <g transform={`translate(${margin.left},${margin.top})`}>
        <GridLine
          type="vertical"
          scale={xScale}
          ticks={5}
          size={height}
          transform={`translate(0, ${height})`}
        />
        <GridLine type="horizontal"  scale={yScaleForAxis} ticks={2} size={width} />
        <GridLine
          type="horizontal"
          className="baseGridLine"
          scale={yScale}
          ticks={1}
          size={width}
          disableAnimation
        />
        {data.map(({ name, items = [], color }) => (
          <Line
            key={name}
            data={items}
            xScale={xScale}
            yScale={yScale}
            color={color}
            animation="left"
          />
        ))}
        <Area 
          data={data[0].items}
          color={data[0].color}
          xScale={xScale}
          yScale={yScale}
        />
        <Axis
          type="left"
          scale={yScaleForAxis}
          transform="translate(0, -10)"
          ticks={5}
          tickFormat={yTickFormat}
        />
        <Axis
          type="bottom"
          className="axisX"
          scale={xScale}
          transform={`translate(10, ${height - height / 6})`}
          ticks={5}
        />
      </g>
    </svg>
  );
};
 
export default MultilineChart;

/** MultilineChart.controller.js */
import { useMemo } from "react";
import * as d3 from "d3";
 
const useController = ({ data, width, height }) => {
  const xMin = useMemo(
    () => d3.min(data, ({ items }) => d3.min(items, ({ date }) => date)),
    [data]
  );
  const xMax = useMemo(
    () => d3.max(data, ({ items }) => d3.max(items, ({ date }) => date)),
    [data]
  );
  const xScale = useMemo(
    () => d3.scaleTime().domain([xMin, xMax]).range([0, width]),
    [xMin, xMax, width]
  );
  const yMin = useMemo(
    () => d3.min(data, ({ items }) => d3.min(items, ({ value }) => value)),
    [data]
  );
  const yMax = useMemo(
    () => d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)),
    [data]
  );
  const yScale = useMemo(() => {
    const indention = (yMax - yMin) * 0.5;
    return d3.scaleLinear()
      .domain([yMin - indention, yMax + indention])
      .range([height, 0]);
  }, [height, yMin, yMax]);
  const yScaleForAxis = useMemo(
    () => d3.scaleBand().domain([yMin, yMax]).range([height, 0]),
    [height, yMin, yMax]
  );
  const yTickFormat = (d) =>
    `${parseFloat(d) > 0 ? "+" : ""}${d3.format(".2%")(d / 100)}`;
 
  return {
    yTickFormat,
    xScale,
    yScale,
    yScaleForAxis
  };
};
 
export default useController;

To see how the chart is rendered using this code, go to

https://codesandbox.io/s/d3react-multiline-chart-version-5-separate-components-k1k5t


At this point, it turned out that we used D3.js just to calculate data for each element, define x and y scales, and add animations as we planned in the beginning.

Using this approach we could easily add other components to our chart, and also use these components for other types of charts. Another advantage was that we did not need to clear the svg content before each rerender: we had to do something like svg.selectAll(".axis").remove(). Every component was independent and had its own life cycle, and knew when to rerender and when to stay the same. It was a great performance improvement, we just had to pay attention to what to put into the hook dependency array.

Step 8: Add more user interactions

As we were building an informative and user-interactive chart, we decided to add some mouse events to our chart, namely tooltip and highlighting bottom axis text on hover. These additions helped us to show data in a more comprehensive and engaging way to our users.

To track mouse events, we needed an area which was invisible and had the size of our svg. The reference to this component we stored in Multiline.js and passed it to the Axis and Tooltip components as an anchorEl.


/** Overlay.js */
import React from "react";
 
/**
* Use Overlay as a wrapper for components that need mouse events to be handled.
* For example: Tooltip, AxisX.
*/
const Overlay = React.forwardRef(({ width, height, children }, ref) => (
  <g>
    {children}
    <rect ref={ref} width={width} height={height} opacity={0} />
  </g>
));
 
export default Overlay;

Then, we needed to modify our Axis component and add an event listener using D3.js.


/** AxisX.js */
import React from "react";
import * as d3 from "d3";
 
const Axis = ({ *** anchorEl, ...props}) => {
  const ref = React.useRef(null);
  ***
  // Add new hook to handle mouse events
  React.useEffect(() => {
    d3.select(anchorEl)
      .on("mouseout.axisX", () => {
        d3.select(ref.current)
          .selectAll("text")
          .attr("opacity", 0.5)
          .style("font-weight", "normal");
        })
      .on("mousemove.axisX", () => {
        const [x] = d3.mouse(anchorEl);
        const xDate = scale.invert(x);
        const textElements = d3.select(ref.current).selectAll("text");
        const data = textElements.data();
        const index = d3.bisector((d) => d).left(data, xDate);
        textElement
          .attr("opacity", (d, i) => (i === index - 1 ? 1 : 0.5))
          .style("font-weight", (d, i) => i === index - 1 ? "bold" : "normal");
     });
 }, [anchorEl, scale]);
 
  return <g ref={ref} transform={transform} {...props} />;
};
 
export default Axis;

/** MultilineChart.js */
import React from "react";
import { Line, Axis, GridLine, Area, Overlay, Tooltip } from "../components";
import useController from "./MultilineChart.controller";
import useDimensions from "../../utils/useDimensions";
 
const MultilineChart = ({ data = [], margin = {} }) => {
  // Add ref for overlay that we will use as anchorEl in Axis
  const overlayRef = React.useRef(null);
  // Add useDimensions custom hook to resize our chart depending on screen size
  const [containerRef, { svgWidth, svgHeight, width, height }] = useDimensions({
    maxHeight: 400,
    margin
  });
  const controller = useController({ data, width, height });
  const { yTickFormat, xScale, yScale, yScaleForAxis } = controller;
 
  return (
    <svg width={svgWidth} height={svgHeight}>
      <g transform={`translate(${margin.left},${margin.top})`}>
        ***
        <Axis
          type="left"
          scale={yScaleForAxis}
          transform="translate(0, -10)"
          ticks={5}
          tickFormat={yTickFormat}
        />
        <Overlay ref={overlayRef} width={innerWidth} height={innerHeight}>
          <Axis
            type="bottom"
            className="axisX"
            anchorEl={overlayRef.current}
            scale={xScale}
            transform={`translate(10, ${height - height / 6})`}
            ticks={5}
          />
          <Tooltip
            className="tooltip"
            anchorEl={overlayRef.current}
            width={width}
            height={height}
            margin={margin}
            xScale={xScale}
            yScale={yScale}
            data={data}
          />
        </Overlay>
      </g>
    </svg>
  );
};
 
export default MultilineChart;


As a result, we’ve implemented reusable and scalable components. For the final implementation of our chart along with Tooltip code and some useful custom hooks like useDimenssion, visit https://codesandbox.io/s/d3react-multiline-chart-version-6-separate-components-tooltip-u9igm.

4. Testing flow for D3.js & React components

After we had built our final implementation, we started testing it to ensure that our application was reliable and fault tolerant.

Here we took the Line as an example and defined the testing strategy to follow.

First we looked at jsx.


<path
  ref={ref}
  d={line(data)}
  stroke={color}
  strokeWidth={3}
  fill="none"
  opacity={0}
  {...props}
/>

The “d” attribute defined the path to be drawn. According to the documentation, it should be a string containing some of 20 commands (letters), numbers, commas, and spaces. We also can check that it didn’t contain NaN or undefined that meant bad calculation.

  1. We could check that the path had properties that we wanted to add to the Line component and color that we defined.
  2. We could test animation, i.e.,if the values of attributes like “stroke-dasharray” or “opacity” changed over time.

/** Line.test.js */
import React from "react";
import { render, waitFor } from "@testing-library/react";
import * as d3 from "d3";
import Line from "../Line";
 
describe("test Line component", () => {
  const TOTAL_LENGTH = 500;
  const xScale = d3.scaleTime();
  const yScale = d3.scaleLinear();
  const data = [
    { date: new Date("2018-01-01"), value: 100 },
    { date: new Date("2019-01-01"), value: 200 },
    { date: new Date("2020-01-01"), value: 300 }
  ];
  beforeAll(() => {
    // Add getTotalLength support for jsdom
    if (!SVGElement.prototype.getTotalLength) {
      SVGElement.prototype.getTotalLength = () => TOTAL_LENGTH;
    }
  });
 
  test("should render path with proper d attribute", () => {
    const { container } = render(
      <svg>
        <Line xScale={xScale} yScale={yScale} data={data} />
      </svg>
    );
    const path = container.querySelector("path");
 
    expect(path.getAttribute("d")).not.toMatch(/[^MmLlHhVvCcSsQqTtAaZz\.\,\s\d]/gi);    
    expect(path.getAttribute("d")).not.toMatch(/NaN|undefined/);
    expect(path.getAttribute("d")).not.toBe("");
  });
 
  test("should render path with custom color and accept additional props", () => {
    const color = "red";
    const className = "my-class";
    const id = "my-id";
    const { container } = render(
      <svg>
        <Line
          xScale={xScale}
          yScale={yScale}
          data={data}
          id={id}
          className={className}
          color={color}
        />
      </svg>
    );
    const path = container.querySelector("path");
 
    expect(path.getAttribute("stroke")).toBe(color);
    expect(path.getAttribute("id")).toBe(id);
    expect(path.getAttribute("class")).toBe(className);
  });
 
  test("should handle fadeIn animation", async () => {
    const { container } = render(
      <svg>
        <Line xScale={xScale} yScale={yScale} data={data} animation="fadeIn" />
      </svg>
    );
    const path = container.querySelector("path");
 
    expect(path.getAttribute("opacity")).toBe("0");
    expect(path.getAttribute("stroke-dasharray")).toBe(null);
    await waitFor(() => expect(path.getAttribute("opacity")).toBe("1"));
 });
 
 test("should handle left animation", async () => {
   const { container } = render(
     <svg>
       <Line xScale={xScale} yScale={yScale} data={data} animation="left" />
     </svg>
   );
   const path = container.querySelector("path");
 
   expect(path.getAttribute("opacity")).toBe("1");
   expect(path.getAttribute("stroke-dasharray")).toBe(
     `${TOTAL_LENGTH},${TOTAL_LENGTH}`
   );
   expect(path.getAttribute("stroke-dashoffset")).toBe(`${TOTAL_LENGTH}`);
 
   await waitFor(() => {
     expect(path.getAttribute("opacity")).toBe("1");
     expect(path.getAttribute("stroke-dasharray")).toBe(
       `${TOTAL_LENGTH},${TOTAL_LENGTH}`
     );
     expect(path.getAttribute("stroke-dashoffset")).toBe("0");
   });
 });
});

5. Conclusion

To sum this up, we have successfully used the D3.js and React libraries when working on the Trading Robo-Advisor R&D project. In this article, we described how we

  • Created the MultiLineChart using D3.js with React
  • Splitted D3.js functionality with React
  • Managed the chart’s re-rendering using React hook dependency
  • Tested the D3.js and React Chart components.

So, if you feel like getting started with D3.js is challenging, just give it a try. This is a proven way to visualize data on a high level, and after you learn the fundamentals, you will be able to visualize data via attractive, interactive, and scalable charts.

Subscribe to our latest Insights

Subscribe to our latest Insights