Build a Bar Chart with D3 and React

31 min read

D3, or Data Driven Documents, is the defacto standard for data visualization on the web. It's an incredibly powerful library that gives you all the tools you need to build just about visualization you can dream of.

Now Available On egghead.io

If you'd like to go through this tutorial in video form, you can check it out in my 30 minute course on egghead.io

D3 is not a collection of predefined charts. Instead, it's a robust set of utilities you can use to ingest and transform data, to map that data to screen values, and ultimately to manipulate the DOM to render your visuals.

When working with React, D3's desire to manipulate the DOM is at odds with React's goal of rendering the UI as a function of state. So how do we handle this? Simply put, we use D3 for everything up to the point where we want to render our output, and then we hand all that data to React for rendering.

In this post, we'll walk through a relatively small example where we build a Bar Chart. We'll take a step-by-step approach and I'll do my best to explain all the pieces along the way. At the end, you'll have a foundation to work on to combine D3 and React to add data visualizations to your React apps.

Even though this is a "small" example, there is a lot going on here, as I tried to cover all the important bits pretty thoroughly.

Getting Setup

Let's get going! We'll start with a React project with D3 added as a dependency.

Create a Project

You'll want a project that has react, react-dom, and d3 installed. You can use Create React App (CRA), Next, Vite with the React template, it's completely up to you. For simplicity, you can start a CodeSandbox project using the React template and add d3 as a dependency.

For these examples, the file structure is based on using CodeSandbox, which will match that of a CRA setup. If you are using a different starting point, you may need to adjust file paths to fit your setup.

Initial Setup

To keep things simple, we'll create our visualization for a small set of sample data that we'll define directly in our JavaScript. We'll also put the majority of our code in a Chart component as we walk through the steps, and we'll render that in the App component.

// App.js
import './styles.css'
import { Chart } from './Chart'

const data = [
  { region: 'East', value: 100 },
  { region: 'West', value: 132 },
  { region: 'North', value: 221 },
  { region: 'South', value: 87 },
]

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
      <Chart data={data} />
    </div>
  )
}
// Chart.js
export function Chart(props) {
  return <div>Chart goes here</div>
}

Let's Create Our Canvas

All of our work in the upcoming steps will result in drawing and positioning shapes in an SVG. To get started we'll define all the dimensions we want to work with and within the SVG, we'll define the bounds where our data elements will be rendered.

Defining Dimensions

We'll start by defining a dimensions object to contain our height, width, and all the margin values we need to layout our svg. We'll define this in App.js and pass it into our Chart component as a prop.

// App.js

+const dimensions = {
+  width: 500,
+  height: 350,
+  margin: { top: 20, left: 50, right: 5, bottom: 25 }
+}

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
-     <Chart data={data} />
+     <Chart data={data} dimensions={dimensions} />
    </div>
  )
}

Render the svg and the Chart Bounds

Now that we have the dimensions defined, we can render the SVG container, we can calculate our bounds, and we can render the bounded area that will hold our data points.

Let's switch over to the Chart component and make use of this dimensions prop.

// Chart.js
export function Chart(props) {
+  const { data, dimensions } = props
+  const { width, height, margin } = dimensions
+
+  const boundedHeight = height - margin.bottom - margin.top
+  const boundedWidth = width - margin.right - margin.left
+
  return <div>Chart goes here</div>
}

We start by grabbing the key dimensions values from props and calculating the width and height for our bounds. This is the area where we'll draw our bars. The remaining space (the margins) will be where we put our axes.

With these values calculated, let's render our svg.

 export function Chart(props) {
   const { data, dimensions } = props
   const { width, height, margin } = dimensions

   const boundedHeight = height - margin.bottom - margin.top
   const boundedWidth = width - margin.right - margin.left

-  return <div>Chart goes here</div>
+  return (
+    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
+      <rect fill="gray" width="100%" height="100%" />
+      <g transform={`translate(${margin.left}, ${margin.top})`}>
+        <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
+      </g>
+    </svg>
+  )
}

Here, we're adding in an svg element that uses our height and width dimensions. Directly inside the svg, I've defined a rect element with full width and height, and a gray fill. This is just to make our svg visible for now, we'll remove this later.

I've also defined a group (g) element. This has a transform applied to it, to shift it right and down based on our margins. This g will represent our entire bounds area.

The transform not only positions the group, it also makes any contained elements positioning relative to the group's top left, which is now the 0,0 coordinate for any of its children.

This group currently contains a rect with a light gray fill and our bounded dimensions just to show our overall layout. We'll remove this rect in a future step.

Now, we can see where our bounds are and what space is left for our margins. We're still not using the boundedWidth or boundedHeight in any permanent code, but they'll come into play more when we define our scales.

import './styles.css'
import { Chart } from './Chart'

const data = [
  { region: 'East', value: 100 },
  { region: 'West', value: 132 },
  { region: 'North', value: 221 },
  { region: 'South', value: 87 }
]

const dimensions = {
  width: 500,
  height: 350,
  margin: { top: 20, left: 50, right: 5, bottom: 25 }
}

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
      <Chart data={data} dimensions={dimensions} />
    </div>
  )
}

Scales in D3

Note
This section is an overview of scales in D3. If you want to skip it and just continue with the example, you can jump to the next step in our walk through

In our work with D3, we'll make heavy use of various scales. Scales are the mechanism by which we take values from our data domain, and map those values to screen values.

When I say screen values, I mean some value that will translate into the way some element is rendered to the screen. In many cases, that means a number that represents the height or width of an element or the x and y values that determine an element's position. Screen values could also include opacity, color, or shape. Pretty much any variable you might want to control in your output can be derived from your data using one of D3's scales.

An Example Linear Scale

Before we get back to our chart, let's look at an example of a linear scale in D3 with scaleLinear(). A linear scale is good for mapping a set of continuous values in your data domain to continuous values in your range of screen values.

In this example, we'll map from a domain between 1 and 10 to pixel values between 0 and 100. We'll use this scale for an x-axis for this hypothetical data set.

const xScale = d3.scaleLinear().domain([1, 10]).range([0, 100])

Our data domain (1-10) is passed into the domain method as an array. We then pass our range (1-100) as an array to the range method. This code returns a scale, which we can call directly.

xScale(1) // 0

xScale(5) // 44.44444444444444

xScale(10) // 100

You can see here that the far ends of the scale are exactly what we see in the data we pass in. The low domain value, maps directly to the low value of the range. The same holds true for the high value in each. For any value in between, the scale calculates the appropriate value in the continuous range of values. In the case, we can imagine the width of our bounds is 100px. Passing in 5 as a data point would map to 44.44444444444444 pixels from the left, allowing us to place an element relative to the value of the item it represents.

Other Scales

D3 has several kinds of scales for various cases. We'll use a couple more scales in this example. You can check out all the scales here.

Creating the xScale for Our Chart

With some familiarity of scales in hand, let's define the xScale we'll need to draw our bar chart!

Chart updates

Let's walk through the changes we want to make to Chart.js. We're going to create our xScale, then we're going to apply the xScale to Chart and draw a single data point. Along the way, we'll touch on accessor functions too.

Create the xScale

First, we'll import scaleLinear and the max utility from d3.

import { scaleLinear, max } from 'd3'

Then we can use these to define our xScale inside the Chart function body. We use scaleLinear to create a scale where our domain is between 0 and the max value of the value key across the objects in our data array. We also set our range to give us corresponding pixel values between 0 and the boundedWidth.

const xScale = scaleLinear()
  .domain([0, max(data, (d) => d.value)])
  .range([0, boundedWidth])
Accessor Functions
Many of the utilities in d3 accept accessor functions. Here, we use the accessor function (d) => d.value as the second argument to max. This tells max to base it's comparisons and return value on the value key of each item.

Apply the xScale to Draw a Data Point

Now that we have a scale for our x-axis, let's draw a bar to represent one of our data points. For now, we'll just pluck off the first data point and we'll use it to draw a bar (rect) in our bounds.

Inside our bounds (the g with the transform), we'll define a new rect element using the xScale to calculate the width from our data

// put this **after** the lightgray `rect` so it isn't covered up

<rect fill="black" height="50px" width={xScale(data[0].value)} />

That data access code is a bit ugly. Don't worry, we'll clean this up as we go. The full return for our Chart component should currently look like this.

  return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
      <rect fill="gray" width="100%" height="100%" />
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
+       <rect fill="black" height="50px" width={xScale(data[0].value)} />
      </g>
    </svg>
  )

You'll notice that we're directly accessing value from a data point. Earlier, we defined an accessor function that does that. Let's refactor this a bit so we can reuse that accessor function. Since this one accesses our data for the x-axis, let's call it xAccessor, and we'll define it just before our xScale.

const xAccessor = (d) => d.value

Now let's use this function in our xScale definition and rendering code

const xScale = scaleLinear()
-   .domain([0, max(data, (d) => d.value)])
+   .domain([0, max(data, xAccessor)])
    .range([0, boundedWidth])

  return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
      <rect fill="gray" width="100%" height="100%" />
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
-       <rect fill="black" height="50px" width={xScale(data[0].value)} />
+         <rect fill="black" height="50px" width={xScale(xAccessor(data[0]))} />
      </g>
    </svg>
  )

This cleans up the code a bit, but this isn't the best place for this. Our data is being passed into the Chart component from App. Presumably, App knows more about the data than Chart. It would make more sense to define this accessor function outside the Chart component and then pass it in as a prop. Now the consuming component that is responsible for the data can also be responsible for defining the accessor functions, making this Chart component slightly more reusable.

Let's refactor Chart and then update App!

In Chart, we'll remove the definition of xAccessor and update our destructuring of props to include xAccessor.

-const { data, dimensions } = props
+const { data, dimensions, xAccessor } = props
-const xAccessor = (d) => d.value

App Updates

Now in App.js, we'll define the xAccessor and pass it down to Chart as a prop.

import './styles.css'
import { Chart } from './Chart'

const data = [
  { region: 'East', value: 100 },
  { region: 'West', value: 132 },
  { region: 'North', value: 221 },
  { region: 'South', value: 87 }
]

+const xAccessor = (d) => d.value

const dimensions = {
  width: 500,
  height: 350,
  margin: { top: 20, left: 50, right: 5, bottom: 25 }
}

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
-      <Chart data={data} dimensions={dimensions} />
+      <Chart data={data} dimensions={dimensions} xAccessor={xAccessor} />
    </div>
  )
}

With all the xScale code in place, we can see how that renders out our first data point as a black bar around 200 pixels wide. We still have some work to do to make this a useful visualization, but we're well on our way. Let's move on to creating our yScale in the next step!

import { scaleLinear, max } from 'd3'

export function Chart(props) {
  const { data, dimensions, xAccessor } = props
  const { width, height, margin } = dimensions

  const boundedHeight = height - margin.bottom - margin.top
  const boundedWidth = width - margin.right - margin.left

  const xScale = scaleLinear()
    .domain([0, max(data, xAccessor)])
    .range([0, boundedWidth])

  return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
      <rect fill="gray" width="100%" height="100%" />
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
        <rect fill="black" height="50px" width={xScale(data[0].value)} />
      </g>
    </svg>
  )
}

Creating the yScale for Our Chart

Now that we have our xScale defined, let's move on to the yScale. This time we'll look at a different type of scale to handle our y-axis. First, we'll start off by defining an accessor function.

Define a yAccessor

We used an accessor function to get value from a data object for our xScale, and we'll use one for grabbing the region key for the yScale. Let's define that in App.js along side our xAccessor.

const xAccessor = (d) => d.value
+const yAccessor = (d) => d.region

And we'll pass it down to Chart as a prop.

-<Chart data={data} dimensions={dimensions} xAccessor={xAccessor} />
+<Chart
+   data={data}
+   dimensions={dimensions}
+   xAccessor={xAccessor}
+   yAccessor={yAccessor}
+ />

Create the yScale

We no longer have a continuous domain, instead, we have a set of discrete values. We still want to map those to the continuous range of pixel values in our svg's height, so for this one we're going to reach for a band scale using d3.scaleBand().

This will allow us to map our 4 specific values into evenly distributed pixel values based on our dimensions. Essentially, we're letting D3 figure out:

  1. How many elements we have
  2. How much space we have in total for those elements
  3. How much space each element gets

Once we have this scale, we can pass in a region name, and get back a value we can use to position and size our elements.

First, let's import scaleBand from d3 by updating the import at the top of Chart.js.

-import { scaleLinear, max } from 'd3'
+import { scaleBand, scaleLinear, max } from 'd3'

We'll grab the yAccessor from props at the top of our function body for Chart.

-const { data, dimensions, xAccessor } = props
+const { data, dimensions, xAccessor, yAccessor } = props

Then we can define our yScale in Chart.js.

// Chart.js

const yScale = scaleBand().domain(data.map(yAccessor)).range([boundedHeight, 0])

Use the yScale

We're going to use our new scale to position our bars along the y-axis, but before that we'll grab the width for each band, using the bandwidth() method on our yScale.

// Chart.js

const bandwidth = yScale.bandwidth()

The value of bandwidth happens to be 66.95121951219512 with our current setup, but we don't need to worry about that. If any of the input for our scale changes in the future (dimensions, number of data points, etc.) this value will be calculated for us based on the new inputs.

With the bandwidth value handy, let's replace our single bar representing the first data point with a map over the data array so we can place a bar on our bounds for each of our data points.

This is what the new return from our Chart component will look like.

return (
  <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
    <rect fill="gray" width="100%" height="100%" />
    <g transform={`translate(${margin.left}, ${margin.top})`}>
      <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
      {data.map((datum) => (
        <rect
          key={yAccessor(datum)}
          fill="black"
          height={bandwidth}
          transform={`translate(0, ${yScale(yAccessor(datum))})`}
          width={xScale(xAccessor(datum))}
        />
      ))}
    </g>
  </svg>
)

We're using data.map to render a rect for each item. We're using the identifier datum here (the singular form of data) for each item in the array. We're using bandwidth for our height. We've also used a transform in combination with our yAccessor and yScale to position each bar along the y-axis. We still have width defined based on our xScale via data obtained with the xAccessor. Lastly, we added a key prop since we're rendering a list of children in React. In this case, we're just using the region value for the key via the yAccessor.

Here's a working demo of the Chart at this stage.

import { scaleBand, scaleLinear, max } from 'd3'

export function Chart(props) {
  const { data, dimensions, xAccessor, yAccessor } = props
  const { width, height, margin } = dimensions

  const boundedHeight = height - margin.bottom - margin.top
  const boundedWidth = width - margin.right - margin.left

  const xScale = scaleLinear()
    .domain([0, max(data, xAccessor)])
    .range([0, boundedWidth])

  const yScale = scaleBand()
    .domain(data.map(yAccessor))
    .range([boundedHeight, 0])

  const bandwidth = yScale.bandwidth()

  return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
      <rect fill="gray" width="100%" height="100%" />
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
        {data.map((datum) => (
          <rect
            key={yAccessor(datum)}
            fill="black"
            height={bandwidth}
            transform={`translate(0, ${yScale(yAccessor(datum))})`}
            width={xScale(xAccessor(datum))}
          />
        ))}
      </g>
    </svg>
  )
}

At this point, we're rendering what is possibly the world's ugliest chart! We'll address that shortly, but first we have some ugly code we could clean up.

Scaled Accessors

We're nesting accessor calls inside calls to our scales. We'll see this pattern enough that we should create functions for it. Let's create scaled accessor functions to clean this up a bit.

Just before our return, after our scale definitions, let's define the following.

const scaledX = (d) => xScale(xAccessor(d))
const scaledY = (d) => yScale(yAccessor(d))

Then we can refactor our rendering code to use these

{data.map((datum) => (
  <rect
    key={yAccessor(datum)}
    fill="black"
    height={bandwidth}
-   transform={`translate(0, ${yScale(yAccessor(datum))})`}
-   width={xScale(xAccessor(datum))}
+   transform={`translate(0, ${scaledY(datum)})`}
+   width={scaledX(datum)}
  />
))}

Our chart is still pretty ugly, but the code is a little easier to read now 😅.

Let's move on to defining and rendering our axes.

Render the X Axis

When using D3 for your DOM manipulation, there are handy helper functions available that will generate an axis for you. Since we're rendering with React, we're going to continue to use D3 for data only, and we'll handle all the rendering in React.

Initial Setup

We're going to create a new component for our x-axis. We'll start by creating a new file in our project. We'll call it BottomAxis.js since the x-axis will run across the bottom of our chart.

For now, we'll define BottomAxis by just returning an svg group.

export function BottomAxis(props) {
  return <g>{/*Render the axis here*/}</g>
}

Then in Chart.js, we'll import the BottomAxis component

import { BottomAxis } from './BottomAxis'

And finally, we'll render it in Chart

return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
      <rect fill="gray" width="100%" height="100%" />
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
        {data.map((datum) => (
          <rect
            fill="black"
            height={bandwidth}
            transform={`translate(0, ${scaledY(datum)})`}
            width={scaledX(datum)}
          />
        ))}
      </g>
+     <BottomAxis />
    </svg>
  )

We're dropping it in the svg, outside of the group that defines our bounds. In the next step, we'll position the axis with it's own transform.

Position the Axis

The first element in our BottomAxis is a g. We'll set a transform on this element, and then everything inside the group will be rendered relative to the group's new 0,0 coordinate.

To position the axis, we'll need to provide it with some values. It's going to need our margin object and the boundedHeight.

In Chart.js, we'll pass this data to BottomAxis as props

// Chart.js

-<BottomAxis />
+<BottomAxis margin={margin} boundedHeight={boundedHeight} />

We'll update BottomAxis to use those values and it'll end up looking like this

// BottomAxis.js

export function BottomAxis(props) {
  const { margin, boundedHeight } = props
  return (
    <g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
      {/*Render the axis here*/}
    </g>
  )
}

Here we're grabbing margin and boundedHeight from props. We use a transform with translate to set the group's x position to match our left margin. Our y is translated to the boundedHeight plus the top margin. This will push the group that contains this axis down into the bottom margin, just below our bounds. Of course, we can't see this because we aren't rendering any visible elements. Let's throw a rect in our group just to see if everything lines up like we expect

// BottomAxis.js

export function BottomAxis(props) {
  const { margin, boundedHeight } = props
  return (
    <g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
-     {/*Render the axis here*/}
+     <rect fill="lightblue" height={margin.bottom} width="445px" />
    </g>
  )
}

I've added this rect with a lightblue background. I've made the height match the bottom margin and I've hard-coded the boundedWidth which happens to be 445px with our current values. I'm not using the value in props here because this rect is temporary and we won't need that value for the finished product.

With this rectangle in place, we can see it lines up with the bottom left edge of our bounds and fills the space reserved for the bottom margin. This is exactly what we want!

Render Ticks

Now that our BottomAxis is properly positioned, we need to render tick marks to represent data values across the axis. For this, we're going to pass our xScale down to the component as a prop from Chart.

// Chart.js

-<BottomAxis margin={margin} boundedHeight={boundedHeight} />
+<BottomAxis
+  margin={margin}
+  boundedHeight={boundedHeight}
+  xScale={xScale}
+/>

Then we can update BottomAxis to grab the xScale from props.

// BottomAxis.js

export function BottomAxis(props) {
-  const { margin, boundedHeight } = props
+  const { margin, boundedHeight, xScale } = props

We can use the ticks() method on our xScale to get back an array of evenly spaced values based on the scale's domain values.

xScale.ticks() // [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 220]

Let's use the array provided by ticks() to render some tick marks. We'll replace the hard-coded rectangle in our g by mapping over xScale.ticks() and for each tick, we'll render a g that contains a rect element. Each rect will be positioned with a transform. Our updated BottomAxis component will now look like this

// BottomAxis

export function BottomAxis(props) {
  const { margin, boundedHeight, xScale } = props

  return (
    <g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
      <rect fill="lightblue" height={margin.bottom} width="445px" />
+     {xScale.ticks().map((tick) => (
+       <g key={tick}>
+         <rect
+           height="15"
+           width="3"
+           transform={`translate(${xScale(tick)})`}
+         />
+       </g>
+     ))}
    </g>
  )
}

We put each tick in a g so we can add labels in the next step.

At this point, our example includes a properly positioned BottomAxis component (as represented by the light blue rect) and that axis is rendering a series of evenly spaced ticks.

export function BottomAxis(props) {
  const { margin, boundedHeight, xScale } = props

  return (
    <g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
     <rect fill="lightblue" height={margin.bottom} width="445px" />
     {xScale.ticks().map((tick) => (
       <g key={tick}>
         <rect
           height="15"
           width="3"
           transform={`translate(${xScale(tick)})`}
         />
       </g>
     ))}
    </g>
  )
}

Render Labels

Our ticks are in place, but there is no way to associate a value with a tick, so we'll add some labels.

Let's add a text element in each of our tick groups.

// BottomAxis.js

return (
  <g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
    {xScale.ticks().map((tick) => (
      <g key={tick}>
        <rect
          height="15"
          width="3"
          transform={`translate(${xScale(tick)})`}
        />
+       <text
+         fontSize=".75rem"
+         dominantBaseline="hanging"
+         textAnchor="middle"
+         transform={`translate(${xScale(tick) + 1.5}, 17)`}
+       >
+       {tick}
+       </text>
      </g>
    ))}
  </g>
)

There's a lot happening here, so let's walk through what we've done.

Adding the text element with tick as the value

We've added a text element, and used the tick value from our loop as the text value.

Size and position

We're using the fontSize attribute and relative sizing to make our font size a bit smaller than the default. We've set the dominantBaseline property to hanging so our placement applies from the top of the text element, rather than the bottom.

To center the text on the tick mark, we've set textAnchor to middle, essentially centering the text (by setting it's 0 x coordinate to the center), and in our transform we've added half the width of a tick mark to our tick's x position (as determined by the xScale). Without this, the text would be slightly left off center on the tick mark.

We've also adjusted the y position in our transform. We've hard coded 17, which is the height of a tick mark, plus a couple pixels for padding. The combination of dominantBaseline and the y transform sets our text just below the tick marks. I'll leave it as exercise to the reader if you'd like to make these items more dynamic.

export function BottomAxis(props) {
  const { margin, boundedHeight, xScale } = props

  return (
    <g transform={`translate(${margin.left}, ${boundedHeight + margin.top})`}>
    <rect fill="lightblue" height={margin.bottom} width="445px" />
    {xScale.ticks().map((tick) => (
      <g key={tick}>
        <rect
           height="15"
           width="3"
           transform={`translate(${xScale(tick)})`}
        />
        <text
          fontSize=".75rem"
          dominantBaseline="hanging"
          textAnchor="middle"
          transform={`translate(${xScale(tick) + 1.5}, 17)`}
        >
          {tick}
        </text>
      </g>
      ))}
    </g>
  )
}

Adjusting Our Margins

Now that we're rendering out our tick marks for the x-axis, it appears that we haven't left enough space in the margins for all of this. The good news is, because we've set everything up to be calculated based on our dimensions object via scales, we can go back to App.js, adjust our margins and everything else will be updated based on the new dimensions.

Back in App.js, let's add some more breathing room. We'll bump the bottom margin up ten pixels to 35, and we'll do the same for the right margin and take it up to 15.

const dimensions = {
  width: 500,
  height: 350,
- margin: { top: 20, left: 50, right: 5, bottom: 25 }
+ margin: { top: 20, left: 50, right: 15, bottom: 35 }
}

And like magic, everything in our chart shifts to work based on the updated margins, no additional work required!

import './styles.css'
import { Chart } from './Chart'

const data = [
  { region: 'East', value: 100 },
  { region: 'West', value: 132 },
  { region: 'North', value: 221 },
  { region: 'South', value: 87 }
]

const xAccessor = (d) => d.value
const yAccessor = (d) => d.region

const dimensions = {
  width: 500,
  height: 350,
  margin: { top: 20, left: 50, right: 15, bottom: 35 }
}

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
      <Chart 
        data={data}
        dimensions={dimensions}
        xAccessor={xAccessor} 
        yAccessor={yAccessor}
      />
    </div>
  )
}

You might notice that the rectangle background for our BottomAxis is spilling over into the right margin. This is fine. The rect element has a hard coded width and the margin is there to reserve that space for our labels. It'll look fine once we remove all the placeholder rect elements.

nice!

You may have noticed that one of our bars has a data value that extends past the last tick in our axis. The value for the north region is 221, but our axis stops at 220.

If our axis is defined based on our scale, and we specified our data domain in our scale definition, why does our axis stop short of our top end value?

When we provide our domain to generate a scale, we're not defining explicit expectations so much as providing guidance that will be used by an algorithm to give us a clean set of round numbers to work with.

Our ticks are optimized for human readable values. Unfortunately, in this case, we have a data point that falls just outside the round numbers D3 gives us. This problem is easily fixed by adding the nice() method to our xScale definition. Adding nice will give us some breathing rooms by extending our ticks to include our full data domain and rounding up.

// Chart.js

const xScale = scaleLinear()
    .domain([0, max(data, xAccessor)])
+   .nice()
    .range([0, boundedWidth])

Just like that, our BottomAxis values shift, our bars shrink to line up with the new mapping based on our xScale and the chart is starting to really come together.

import './styles.css'
import { Chart } from './Chart'

const data = [
  { region: 'East', value: 100 },
  { region: 'West', value: 132 },
  { region: 'North', value: 221 },
  { region: 'South', value: 87 }
]

const xAccessor = (d) => d.value
const yAccessor = (d) => d.region

const dimensions = {
  width: 500,
  height: 350,
  margin: { top: 20, left: 50, right: 15, bottom: 35 }
}

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
      <Chart 
        data={data}
        dimensions={dimensions}
        xAccessor={xAccessor} 
        yAccessor={yAccessor}
      />
    </div>
  )
}

Render the Y Axis

We have some indication of what the width of the bars represents (roughly), but we still don't know what each bar represents. Let's add some labels down the left site of our chart.

Initial Setup for the Left Axis

Just like with BottomAxis, we'll define the y-axis in a new component, we'll call this one LeftAxis.js

// LeftAxis.js

export function LeftAxis(props) {
  return <g>{/* Render Left Axis Labels */}</g>
}

We'll import it into Chart.js

// Chart.js

import { LeftAxis } from './LeftAxis'

Then we can render it

// Chart.js

  <BottomAxis
        margin={margin}
        boundedHeight={boundedHeight}
        xScale={xScale}
      />
+ <LeftAxis />
</svg>

We're also going to want to position this axis, so let's pass in our margin object, and we'll need the yScale in the next step, so let's also add that now.

// Chart.js

-<LeftAxis />
+<LeftAxis margin={margin} yScale={yScale} />

Now we can use our margin object to position our g for the LeftAxis. We'll destructure props, add a transform that sets the y position to account for the top margin, and since we can't see anything with just a group, let's add a temporary rect to verify our positioning.

// LeftAxis.js

export function LeftAxis(props) {
  const { margin, yScale } = props
  return (
    <g transform={`translate(0, ${margin.top})`}>
      <rect fill="lightblue" height="295" width={margin.left} />
    </g>
  )
}

Render Labels

Now that we've properly positioned the LeftAxis, let's render labels for our regions.

We'll start by grabbing the domain() from our yScale. This gives us our labels.

yScale.domain() // ['East', 'West', 'North', 'South']

First Pass

Let's map over the values returned from domain and render text elements to them. We'll call each individual item band here since we're looking at the individual bands in our band scale. We'll use the band as the text value and apply a transform by applying the yScale to our band value. We also need a key prop since we're looping through values, so we'll just use band.

The LeftAxis component should look like this now.

// LeftAxis.js

export function LeftAxis(props) {
  const { margin, yScale } = props
  return (
    <g transform={`translate(0, ${margin.top})`}>
      {yScale.domain().map((band) => (
        <text key={band} transform={`translate(0, ${yScale(band)})`}>
          {band}
        </text>
      ))}
    </g>
  )
}

When we render this, we'll see our labels running down the left side of the chart, and the bottom of the text will align with the top of its corresponding bar.

import './styles.css'
import { Chart } from './Chart'

const data = [
  { region: 'East', value: 100 },
  { region: 'West', value: 132 },
  { region: 'North', value: 221 },
  { region: 'South', value: 87 }
]

const xAccessor = (d) => d.value
const yAccessor = (d) => d.region

const dimensions = {
  width: 500,
  height: 350,
  margin: { top: 20, left: 50, right: 15, bottom: 35 }
}

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
      <Chart 
        data={data}
        dimensions={dimensions}
        xAccessor={xAccessor} 
        yAccessor={yAccessor}
      />
    </div>
  )
}

Additional Formatting & Positioning

Let's format our text elements a bit and position them so they're center aligned with the bar they represent. We'll start by adjusting our transform. We know the value we get back from yScale(band) will put the text at the top of the bar. To center it vertically, we'll add half the bandwidth value. So our transform becomes

transform={`translate(0, ${yScale(band) + yScale.bandwidth() / 2})`}

This will align the bottom of the text with our bar. To finish the alignment, we'll change the dominantBaseline property to middle. Finally, we'll make the labels a little more prominent by changing fontWeight to bold.

// LeftAxis.js

export function LeftAxis(props) {
  const { margin, yScale } = props
  return (
    <g transform={`translate(0, ${margin.top})`}>
      {yScale.domain().map((band) => (
-       <text key={band} transform={`translate(0, ${yScale(band)})`}>{band}</text>
+       <text
+         key={band}
+         transform={`translate(0, ${yScale(band) + yScale.bandwidth() / 2})`}
+         dominantBaseline="middle"
+         fontWeight="bold"
+       >
+         {band}
+       </text>
      ))}
    </g>
  )
}

Padding

Our bars are all very close to one another which is not great for readability. We can fix this while letting D3 continue to do the math for us. The band scale in D3 allows us to specify padding right on the scale definition.

All we need to do is add the padding method to our yScale definition. Padding takes a value between 0 and 1. Let's use 0.1 to create some padding, while retaining nice wide bars. Play around with this value and find one that appeals to you.

// Chart.js

const yScale = scaleBand()
    .domain(data.map(yAccessor))
    .range([boundedHeight, 0])
+   .padding(0.1)
import './styles.css'
import { Chart } from './Chart'

const data = [
  { region: 'East', value: 100 },
  { region: 'West', value: 132 },
  { region: 'North', value: 221 },
  { region: 'South', value: 87 }
]

const xAccessor = (d) => d.value
const yAccessor = (d) => d.region

const dimensions = {
  width: 500,
  height: 350,
  margin: { top: 20, left: 50, right: 15, bottom: 35 }
}

export default function App() {
  return (
    <div>
      <h1>A Bar Chart with React & D3</h1>
      <Chart 
        data={data}
        dimensions={dimensions}
        xAccessor={xAccessor} 
        yAccessor={yAccessor}
      />
    </div>
  )
}

UI Cleanup & Additional Labels

Remove Those Ugly rects

Finally! We get to start making this thing look less terrible. Since we have our axes defined and rendered, our data points are in our bounds, we don't need the visual reference (noise?) of the ugly rect elements we started with. Let's just delete those.

// Chart.js

return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
-     <rect fill="gray" width="100%" height="100%" />
      <g transform={`translate(${margin.left}, ${margin.top})`}>
-       <rect fill="lightgray" width={boundedWidth} height={boundedHeight} />
        {data.map((datum) => (
          <rect
            key={yAccessor(datum)}
            fill="black"
            height={bandwidth}
            transform={`translate(0, ${scaledY(datum)})`}
            width={scaledX(datum)}
          />
        ))}
      </g>
      <BottomAxis
        margin={margin}
        boundedHeight={boundedHeight}
        xScale={xScale}
      />
      <LeftAxis margin={margin} yScale={yScale} />
    </svg>
  )

That's looking better, but there's still some room for improvement here.

Additional Labels

Looking at the axis in relation to the bars gives us an indication of where values fall roughly, but we don't have exact figures depicted anywhere. Let's add some additional labels so we can see values relative to each other, but also quickly identify the exact value of individual bars.

When we map over the data in the Chart component, let's wrap our rect in a g. We'll move the key prop to the g and we'll move the transform along with it.

// Chart.js

 <g transform={`translate(${margin.left}, ${margin.top})`}>
    {data.map((datum) => (
-     <rect
-       key={yAccessor(datum)}
-       fill="black"
-       height={bandwidth}
-       transform={`translate(0, ${scaledY(datum)})`}
-       width={scaledX(datum)}
-     />
+     <g
+       key={yAccessor(datum)}
+       transform={`translate(0, ${scaledY(datum)})`}
+     >
+           <rect fill="black" height={bandwidth} width={scaledX(datum)} />
+     </g>
    ))}
  </g>

That should leave everything looking the same, we've just shifted the y positioning out to the group.

Now we'll add a text element right after the rect inside the group we just defined.

// Chart.js

<text
  fontWeight="bolder"
  dominantBaseline="hanging"
  transform={`translate(${xScale(xAccessor(datum)) + 4})`}
>
  {xAccessor(datum)}
</text>

We made the font bold, and set the dominantBaseline to hanging so the top of the text aligns with the top of the bar. We also applied a transform using our scaledX accessor to position the text 4px to the right of the bar's width. Finally, using the xAccessor, we use value from our datum as the text label.

With all of these changes applied, the full return for Chart should now look like this

// Chart.js

return (
  <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
    <g transform={`translate(${margin.left}, ${margin.top})`}>
      {data.map((datum) => (
        <g key={yAccessor(datum)} transform={`translate(0, ${scaledY(datum)})`}>
          <rect fill="black" height={bandwidth} width={scaledX(datum)} />
          <text
            fontWeight="bolder"
            dominantBaseline="hanging"
            transform={`translate(${scaledX(datum) + 4})`}
          >
            {xAccessor(datum)}
          </text>
        </g>
      ))}
    </g>
    <BottomAxis margin={margin} boundedHeight={boundedHeight} xScale={xScale} />
    <LeftAxis margin={margin} yScale={yScale} />
  </svg>
)

It's getting better, but I think this chart could use a little color!

Add a Color Scale

Scales aren't just for numbers and labels, we can also define a color scale. We'll keep this one simple, and map our data items to an array of 4 color values.

Define the Color Scale

We'll use an ordinal scale this time, to map items in a discrete domain (regions) to values in a discrete range (a list of hex color values).

We'll start by importing scaleOrdinal from D3.

// Chart.js

-import { scaleBand, scaleLinear, max } from 'd3'
+import { scaleBand, scaleLinear, scaleOrdinal, max } from 'd3'

Then we'll add the following scale definition in the Chart component's function body.

// Chart.js

const colorScale = scaleOrdinal()
  .domain(data.map(yAccessor))
  .range(['#005f73', '#0a9396', '#ee9b00', '#ae2012'])

Apply the Color Scale

And now that we have a color scale to work with, let's apply it to the group that contains both the bar and the label for a region's value. We'll also remove the explicit fill from the rect and allow it to inherit the fill from the containing g.

// Chart.js

  return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        {data.map((datum) => (
          <g
            key={yAccessor(datum)}
            transform={`translate(0, ${scaledY(datum)})`}
+           fill={colorScale(yAccessor(datum))}
          >
-           <rect fill="black" height={bandwidth} width={scaledX(datum)} />
+           <rect height={bandwidth} width={scaledX(datum)} />
            <text
              fontWeight="bolder"
              dominantBaseline="hanging"
              transform={`translate(${scaledX(datum) + 4})`}
            >
              {xAccessor(datum)}
            </text>
          </g>
        ))}
      </g>
      <BottomAxis
        margin={margin}
        boundedHeight={boundedHeight}
        xScale={xScale}
      />
      <LeftAxis margin={margin} yScale={yScale} />
    </svg>
  )

And with those changes in place, we have a decent looking Chart that accurately represents our sample data 🎉.

import { scaleBand, scaleLinear, scaleOrdinal, max } from 'd3'
import { BottomAxis } from './BottomAxis'
import { LeftAxis } from './LeftAxis'

export function Chart(props) {
  const { data, dimensions, xAccessor, yAccessor } = props
  const { width, height, margin } = dimensions

  const boundedHeight = height - margin.bottom - margin.top
  const boundedWidth = width - margin.right - margin.left

  const xScale = scaleLinear()
    .domain([0, max(data, xAccessor)])
    .nice()
    .range([0, boundedWidth])

  const yScale = scaleBand()
    .domain(data.map(yAccessor))
    .range([boundedHeight, 0])
    .padding(0.1)

  const colorScale = scaleOrdinal()
    .domain(data.map(yAccessor))
    .range(['#005f73', '#0a9396', '#ee9b00', '#ae2012'])

  const bandwidth = yScale.bandwidth()

  const scaledX = (d) => xScale(xAccessor(d))
  const scaledY = (d) => yScale(yAccessor(d))

  return (
    <svg xmlns="http://www.w3.org/2000/svg" width={width} height={height}>
      <g transform={`translate(${margin.left}, ${margin.top})`}>
        {data.map((datum) => (
          <g
            key={yAccessor(datum)}
            transform={`translate(0, ${scaledY(datum)})`}
            fill={colorScale(yAccessor(datum))}
          >
            <rect height={bandwidth} width={scaledX(datum)} />
            <text
              fontWeight="bolder"
              dominantBaseline="hanging"
              transform={`translate(${xScale(xAccessor(datum)) + 4})`}
            >
              {xAccessor(datum)}
            </text>
          </g>
        ))}
      </g>
      <BottomAxis
        margin={margin}
        boundedHeight={boundedHeight}
        xScale={xScale}
      />
      <LeftAxis margin={margin} yScale={yScale} />
    </svg>
  )
}

Conclusion

Phew! That was a lot! 😅

While we didn't build the world's most exciting data visualization, we did cover a lot of ground and we hit on some key fundamentals of D3. Many of the steps we took here are going to apply to just about any data visualization you create using D3.

We'll always want to give our drawing area some structure, creating areas for axes, and leaving space for showing data points. We'll always want to use one or more of the various scales to map from data to pixels. Accessors are widely supported in the various D3 utilities, so we'll typically create those. And finally, we'll use these values to draw a compelling graphic visualization of whatever data we're trying to communicate.