Get Started with D3 and React Hooks — Creating a Bar Chart

May Chen
NEXL Engineering
Published in
5 min readApr 14, 2021
A monthly engagement chart over the past 12 month
A simple bar chart on the table that helps users understand the data at the first sight

Goal

To create the above bar chart with data that looks like this:

[
{
"interactionCount": 1506,
"month": "2021-01"
},
{
"interactionCount": 1446,
"month": "2021-02"
},
{
"interactionCount": 1503,
"month": "2021-03"
},
...
]

First Step — D3 with React

First of all we need to install d3 package and types for d3 if you are using typescript:

yarn add d3
yarn add @types/d3 -D

And we have a react component where we mount the chart on:

import React, { useEffect } from "react";
import { drawChart } from "./chart";
interface IEngagementChartProps {
engagement: {
month:string;
interactionCount: number;
}[];
}
export const EngagementChart: React.FC<IEngagementChartProps> = ({
engagement
}) => {
useEffect(() => {
drawChart(engagement); // this is where the magic happens
}, []);
return
<div
className={classes.root}
id="engagement" // d3 will try to find this element by id
/>;
};

the useEffect hook makes sure drawChart() only runs after the component is mounted, otherwise d3 won’t be able to find the element #engagement to mount the chart on.

Creating the Chart

Source code here if you don’t want to continue reading =)

Drawing chart container and define width and height

So firstly we want to create a <svg /> element inside <div id="engagement" /> 30 px high and 80 px wide:

const width = 80;
const height = 30;
const svg = d3
.select(`#engagement-${id}`)
.append("svg")
.attr("height", height)
.attr("width", width);

Calculating the past 12 months

A tiny issue with our engagement data, it only has the months where there are interactions, meaning it doesn’t always have all the past 12 months. It might look like this:

[
{
"interactionCount": 1506,
"month": "2020-05"
},
{
"interactionCount": 1446,
"month": "2020-12"
},
{
"interactionCount": 1503,
"month": "2021-03"
},
]

So we have to do a little trick to get all the last 12 months as the format of yyyy-mm so later on we can map the data to the chart.

const minDate = new Date(
new Date().setFullYear(new Date().getFullYear() - 1)
); // get today 1 year ago
const maxDate = new Date(); // get todayconst months = d3.timeMonth
.range(d3.timeMonth.ceil(minDate), maxDate)
.map((month) => month.toISOString().substring(0, 7));

With the code above, we get an array that looks like:

["2020-04", "2020-05", "2020-06", ..., "2021-02", "2021-03"]

Scale of the chart

Once we defined how big the chart is, we want to map our data to the size of the chart.

It’s simple on the x axis, we want to evenly divide the chart into 12 bars, so we can use a d3.scaleBand() here:

const x = d3.scaleBand().domain(months).range([0, width]).padding(0.3);

Basically, domain() takes the length of the array months, and range would map each element from domain evenly on the chart.

Documentation on d3.scaleBand()

In the y axis, we want to have a cap on the number of interactions that we care about and map the values to the chart. So we use d3.scaleLinear:

const interactionCeiling = 60;const y = d3.scaleLinear().domain([0, interactionCeiling]).range([0, height]);

Here, domain takes the minimum and maximum value of the data and range maps it to our desired size.

Documentation on d3.scaleLinear()

Drawing 12 grey bars for each month

Now inside the <svg />, we want to create 12 grey bars representing each month of the past year. d3’s data join does the trick.

svg
.selectAll("rect.month")
.data(months)
.enter()
.append("rect")
.attr("class", "month")
.attr("x", (month) => x(month) || "")
.attr("y", 0)
.attr("width", x.bandwidth())
.attr("height", height)
.attr("fill", "#eeeeee");

At first I found this super counterintuitive comparing to doing a for loop, but what this code does is for each entry of the data (remember the months array we have), it will try to find if the corresponding element exists, and create a new element if not.

So we will create a bar for each month, we use the scale function x to get the x position and the width. And on Y axis, everything is always the same, starting from position 0 with the height of chart height.

Documentation on data join

You shall see something like this with the code above

Drawing the engagement bars

svg
.selectAll("rect.engagement")
.data(engagement)
.enter()
.append("rect")
.attr("class", "engagement")
.attr("x", (dist) => x(dist.month) || "")
.attr(
"y",
(dist) => height - y(Math.min(interactionCeiling, dist.interactionCount))
)
.attr("width", x.bandwidth())
.attr("height", (dist) =>
y(Math.min(interactionCeiling, dist.interactionCount))
)
.attr("fill", blue[600])
.append("title")
.text((dist) => `${dist.month}: ${dist.interactionCount} interactions`);

Finally, we can draw our engagement bars onto the chart. So we grab our data engagement and create a <rect /> for each entry. The key is where and how long we draw each bar.

Position X: We pass month(e.g. “2020–12”) into the scale function x, it will try to match the string with the values in months array and determine what the position is, just the same as what we do with the grey bars.

Width: They are all the same wide as the grey bars, so x.bandWidth().

Position Y: so the scale function y gives us how tall the bar shall be mapped to the size of the chart. As the graph show below, it will be height - y(interaction count of that month). The catch is, we want to cap it to the maximum number of interactions that we care about(interactionCeiling), so height — y(interactionCount or interactionCeiling whichever is smaller).

Height: simply y(interactionCount or interactionCeiling whichever is smaller).

the math

By now, you shall have a cute little bar chart. Thanks for reading =)

yay!

--

--

May Chen
NEXL Engineering

A developer who occasionally has existential crisis and thinks if we are heading to the wrong direction, technology is just getting us there sooner.