React for Data Visualization
Student Login

Build scalable dataviz components with full integration

This section builds up your mental models for dataviz components through the class-based approach. If you don't care about those details, you can jump ahead to React Hooks.

As useful as blackbox components are, we need something better if we want to leverage React's rendering engine. The blackbox approach in particular struggles with scale. The more charts and graphs and visualizations on your screen, the slower it gets.

Someone once came to my workshop and said "We used the blackbox approach and it takes several seconds to re-render our dashboard on any change. I'm here to learn how to do it better."

In our full-feature integration, React does the rendering and D3 calculates the props.

Our goal is to build controlled components that listen to their props and reconcile that with D3's desire to use a lot of internal state.

There are two situations we can find ourselves in:

  1. We know for a fact our component's props never change
  2. We think props could change

It's easiest to show you with an example.

Let's build a scatterplot step by step. Take a random array of two-dimensional data, render in a loop. Make magic.

Something like this πŸ‘‡

A simple scatterplot

You've already built the axes! Copy pasta time.

Props don't change

Ignoring props changes makes our life easier, but the component less flexible and reusable. Great when you know in advance that there are features you don't ned to support.

Like, no filtering your data or changing component size πŸ‘‰ means your D3 scales don't have to change.

When our props don't change, we follow a 2-step integration process:

  • set up D3 objects as class properties
  • output SVG in render()

We don't have to worry about updating D3 objects on prop changes. Work done πŸ‘Œ

An unchanging scatterplot

We're building a scatterplot of random data. You can see the final solution on CodeSandbox

Here's the approach πŸ‘‡

  • stub out the basic setup
  • generate random data
  • stub out Scatterplot
  • set up D3 scales
  • render circles for each entry
  • add axes

I recommend creating a new CodeSandbox, or starting a new app with create-react-app. They should work the same.

Basic setup

Make sure you have d3 added as a dependency. Then add imports in your App.js file.

// ./App.js
import * as d3 from "d3"
import Scatterplot from "./Scatterplot"

Add an <svg> and render a Scatterplot in the render method. This will throw an error because we haven't defined the Scatterplot yet and that's okay.

// ./App.js
function App() {
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
<svg width="800" height="800">
<Scatterplot x={50} y={50} width={300} height={300} data={data} />
</svg>
</div>
)
}

CodeSandbox adds most of that code by default. If you're using create-react-app, your App component has different markup. That's okay too.

We added this part:

<svg width="800" height="800">
<Scatterplot x={50} y={50} width={300} height={300} data={data} />
</svg>

An <svg> drawing area with a width and a height. Inside, a <Scatterplot that's positioned at (50, 50) and is 300px tall and wide. We'll have to listen to those props when building the Scatterplot.

It also accepts data.

Random data

We're using a line of code to generate data for our scatterplot. Put it in App.js. Either globally or within the App function. Doesn't matter because this is an example.

const data = d3.range(100).map((_) => [Math.random(), Math.random()])

d3.range returns a counting array from 0 to 100. Think [1,2,3,4 ...].

We iterate over this array and return a pair of random numbers for each entry. These will be our X and Y coordinates.

Scatterplot

Our scatterplot goes in a new Scatterplot.js file. Starts with imports and an empty React component.

// ./Scatterplot.js
import React from "react"
import * as d3 from "d3"
class Scatterplot extends React.Component {
render() {
const { x, y, data, height } = this.props
return <g transform={`translate(${x}, ${y})`}></g>
}
}
export default Scatterplot

Import dependencies, create a Scatterplot component, render a grouping element moved to the correct x and y position. Nothing too strange yet.

D3 scales

Now we define D3 scales as component properties. We're using the class field syntax that's common in React projects.

Technically a Babel plugin, but comes by default with CodeSandbox React projects and create-react-app setup. As far as I can tell, it's a common way to write React components.

// ./Scatterplot.js
class Scatterplot extends React.Component {
xScale = d3
.scaleLinear()
.domain([0, 1])
.range([0, this.props.width]);
yScale = d3
.scaleLinear()
.domain([0, 1])
.range([this.props.height, 0]);

We're defining this.xScale and this.yScale as linear scales. Their domains go from 0 to 1 because that's what Math.random returns and their ranges describe the size of our scatterplot component.

Idea being that these two scales will help us take those tiny variations in datapoint coordinates and explode them up to the full size of our scatterplot. Without this, they'd overlap and we wouldn't see anything.

Circles for each entry

Rendering our data points is a matter of looping over the data and rendering a <circle> for each entry. Using our scales to define positioning.

// ./Scatterplot.js
return (
<g transform={`translate(${x}, ${y})`}>
{data.map(([x, y]) => (
<circle cx={this.xScale(:satisfied:} cy={this.yScale(y)} r="5" />
))}
</g>
);

In the return statement of our render render method, we add a data.map with an iterator method. This method takes our datapoint, uses array destructuring to get x and y coordinates, then uses our scales to define cx and cy attributes on a <circle> element.

Add axes

You can reuse axes from our earlier exercise. Or copy mine from the CodeSandbox

Mine take a scale and orientation as props, which makes them more flexible. Means we can use the same component for both the vertical and horizontal axis on our Scatterplot.

Put the axis code in Axis.js, then augment the Scatterplot like this πŸ‘‡

import Axis from "./Axis";
// ...
return (
<g transform={`translate(${x}, ${y})`}>
{data.map(([x, y]) => (
<circle cx={this.xScale(:satisfied:} cy={this.yScale(y)} r="5" />
))}
<Axis x={0} y={0} scale={this.yScale} type="Left" />
<Axis x={0} y={height} scale={this.xScale} type="Bottom" />
</g>
);

Vertical axis takes the vertical this.yScale scale, orients to the Left and we position it top left. The horizontal axis takes the horizontal this.xScale scale, orients to the Bottom, and we render it bottom left.

Your Scatterplot should now look like this

Rendered basic scatterplot

Props might update

The story is a little different when our props might update. Since we're using D3 objects to calculate SVG properties, we have to make sure those objects are updated before we render.

No problem in React 15: Update in componentWillUpdate. But since React 16.3 we've been told never to use that again. Causes problems for modern async rendering.

The official recommended solution is that anything that used to go in componentWillUpdate, can go in componentDidUpdate. But not so fast!

Updating D3 objects in componentDidUpdate would mean our visualization always renders one update behind. Stale renders! 😱

The new getDerivedStateFromProps to the rescue. Our integration follows a 3-step pattern:

  • set up D3 objects in component state
  • update D3 objects in getDerivedStateFromProps
  • output SVG in render()

getDerivedStateFromProps is officially discouraged, and yet the best tool we have to make sure D3 state is updated before we render.

Because React calls getDerivedStateFromProps on every component render, not just when our props actually change, you should avoid recalculating complex things too often. Use memoization helpers, check for changes before updating, stuff like that.

An updateable scatterplot

Let's update our scatterplot so it can deal with resizing and updating data.

3 steps πŸ‘‡

  • add an interaction that resizes the scatterplot
  • move scales to state
  • update scales in getDerivedStateFromProps

You can see my final solution on CodeSandbox. I recommend you follow along updating your existing code.

Resize scatterplot on click

To test our scatterplot's adaptability, we have to add an interaction: Resize the scatterplot on click.

That change happens in App.js. Click on the <svg>, reduce width and height by 30%.

Move sizing into App state and add an onClick handler.

// App.js
class App extends React.Component {
state = {
width: 300,
height: 300
};
onClick = () => {
const { width, height } = this.state;
this.setState({
width: width * 0.7,
height: height * 0.7
});
};
render() {
const { width, height } = this.state;

We changed our App component from a function to a class, added state with default width and height, and an onClick method that reduces size by 30%. The render method reads width and height from state.

Now gotta change rendering to listen to these values and fire the onClick handler.

// App.js
<svg width="800" height="800" onClick={this.onClick}>
<Scatterplot x={50} y={50} width={width} height={height} data={data} />
</svg>

Similar rendering as before. We have an <svg> that contains a <Scatterplot>. The svg fires this.onClick on click events and the scatterplot uses our width and height values for its props.

If you try this code now, you should see a funny effect where axes move, but the scatterplot doesn't resize.

Axes move, scatterplot doesn't resize

Peculiar isn't it? Try to guess why.

Move scales to state

The horizontal axis moves because it's render at height vertical coordinate. Datapoints don't move because the scales that position them are calculated once – on component mount.

First step to keeping scales up to date is to move them from component values into state.

// Scatterplot.js
class Scatterplot extends React.Component {
state = {
xScale: d3
.scaleLinear()
.domain([0, 1])
.range([0, this.props.width]),
yScale: d3
.scaleLinear()
.domain([0, 1])
.range([this.props.height, 0])
};

Same scale definition code we had before. Linear scales, domain from 0 to 1, using props for ranges. But now they're wrapped in a state = {} object and it's xScale: d3 ... instead of xScale = d3 ....

Our render function should use these as well. Small change:

// Scatterplot.js
render() {
const { x, y, data, height } = this.props,
{ yScale, xScale } = this.state;
return (
<g transform={`translate(${x}, ${y})`}>
{data.map(([x, y]) => <circle cx={xScale(:satisfied:} cy={yScale(y)} r="5" />)}

We use destructuring to take our scales from state, then use them when mapping over our data.

Clicking on the SVG produces the same result as before, but we're almost there. Just one more step.

Update scales in getDerivedStateFromProps

Last step is to update our scales' ranges in getDerivedStateFromProps. This method runs every time React touches our component for any reason.

// Scatterplot.js
class Scatterplot extends React.PureComponent {
// ..
static getDerivedStateFromProps(props, state) {
const { yScale, xScale } = state;
yScale.range([props.height, 0]);
xScale.range([0, props.width]);
return {
...state,
yScale,
xScale
};
}

Take scales from state, update ranges with new values, return new state. Nice and easy.

Notice that getDerivedStateFromProps is a static method shared by all instances of our Scatterplot component. You have no reference to a this and have to calculate new state purely from the props and state passed into your method.

It's a lot like a Redux reducer, if that helps you think about it. If you don't know what Redux reducers are, don't worry. Just remember to return a new version of component state.

Your Scatterplot should now update its size on every click.

Scatterplot resizes

Previous:
D3blackbox magic trick - render anything in 30sec (5:11)
Next:
Extra flexibility with render props (6:36)
Created by Swizec with ❀️