React and d3

Updated Feb 5, 2018

React is great. d3 is great. Both excel at their core missions but integrating the two proposes some challenges. React wants to control all state and the DOM, whereas d3 wants to bind data to elements and control related state transitions.

There is a lot of conflicting advice about how to include d3 components in a React application, though the advice tends toward a React bias, whereby solutions are forced into the “React way” and React controls everything. If you’re willing to part from the gospel, however, it’s actually pretty easy to get the best of both worlds.

The solution is to simply hand off control from React to d3 when it comes to creating charts. This is anathema to React purists, might sit well with d3 purists, but in any event works. In the following we’ll create a small React application with an entry App component. This component in turn owns a ChartContainer component. The ChartContainer owns an instance of the Chart class, a plain vanilla Javascript class that holds all of the d3 code.

React still manages the application state, passing props into our Chart instance, but does not manage the DOM state for the chart. This enables us to use d3 patterns and transitions as usual without worrying about keeping the React virtual DOM up to speed.

The data for the chart are passed in as props, as is the chart size, which we update on window resize events. The end result will be the following contrived chart. Click the “refresh” button to generate new random data and confirm that the transitions are working; try resizing your browser window to see the chart size update. The full code is available here though the relevant files are described inline below.

The main entry point for the application is the App class, which renders a button (to generate new data) and the ChartContainer:

App.jsx Download Copy
import React from "react";
import ChartContainer from "./ChartContainer";
function getData () {
  const n = 10 + 10 * Math.round(Math.random());
  let data = [];
  for (let i = 0; i < n; i++) {
    data.push(20 * Math.random());
  }
  return data;
}
export default class App extends React.Component {
  constructor (props) {
    super(props);
    this.state = {
      data: getData(),
      size: [800, 300]
    };
    this.handleClickBtn = this.handleClickBtn.bind(this);
    this.handleWindowResize = this.handleWindowResize.bind(this);
  }
  componentDidMount () {
    this.setState({
      size: [this.el.parentNode.offsetWidth, 300]
    });
    window.addEventListener('resize', this.handleWindowResize, false);
  }
  componentWillUnmount () {
    window.removeEventListener('resize', this.handleWindowResize);
  }
  handleClickBtn () {
    this.setState({
      data: getData()
    });
  }
  handleWindowResize () {
    this.setState({
      size: [this.el.parentNode.offsetWidth, 300]
    });
  }
  render () {
    return (
      <div ref={el => {this.el = el}}>
        <button onClick={this.handleClickBtn}>Refresh</button>
        <ChartContainer
          data={this.props.data}
          size={this.props.size}
        />
      </div>
    );
  }
}

This is a pretty standard React component. We store the data and chart size in state and pass those along to the ChartContainer component during rendering. Clicking the button generates a new data set, while a window resize event triggers a new value for the chart size. The ChartContainer class is as follows:

ChartContainer.jsx Download Copy
import React from "react";
import ReactDOM from "react-dom";
export default class ChartContainer extends React.Component {
  componentDidMount () {
    this.chart = new Chart(ReactDOM.findDOMNode(this), this.props);
  }
  componentDidUpdate () {
    this.chart.update(this.props);
  }
  componentWillUnmount () {
    delete this.chart;
  }
  render () {
    return <div></div>;
  }
}

The ChartContainer class is fairly straightforward. When the component mounts, add the chart. When props update, update the chart. When the component unmounts, delete the chart. And we just render an empty div container that’s the parent for the chart.

The Chart class referenced above is as follows:

Chart.js Download Copy
import {
  axisBottom as d3_axisBottom,
  axisLeft as d3_axisLeft,
  curveBasis as d3_curveBasis,
  axisLine as d3_line,
  max as d3_max,
  range as d3_range,
  scaleBand as d3_scaleBand,
  scaleLinear as d3_scaleLinear,
  select as d3_select,
  transition as d3_transition,
} from "d3";
export default class Chart {
  constructor (node, props) {
    this.render(node, props);
  }
  render (node, props) {
    // Store original props to pass to update() call later.
    const originalProps = props;
    props = this.normalizeProps(props);
    // Main element
    const svg = this.svg = d3_select(node).append('svg');
      .attr('id', 'svg')
      .attr('width', props.outerWidth)
      .attr('height', props.outerHeight)
      .append('g')
        .attr('transform', 'translate(' + props.margin.left + ',' + props.margin.top + ')');
    // Scales and axes.
    this.x = d3_scaleBand().range([0, props.innerWidth]).paddingOuter(0.1);
    this.y = d3_scaleLinear().range([props.innerHeight, 0]);
    this.xAxis = d3_axisBottom(this.x);
    this.yAxis = d3_axisLeft(this.y);
    // Draw axes.
    svg.append('g')
      .attr('class', 'x axis')
      .attr('transform', 'translate(0,' + props.innerHeight + ')');
    svg.append('g')
      .attr('class', 'y axis');
    // Container for bars.
    svg.append('g')
      .attr('id', 'bars');
    // Create the line
    svg.append('path')
      .attr('id', 'main-line')
      .attr('fill', 'none')
      .attr('stroke-width', 2)
      .attr('stroke', '#b44');
    // Call initial update.
    this.update(props);
  }
  update (props) {
    props = this.normalizeProps(props);
    const svg = this.svg;
    const x = this.x;
    const y = this.y;
    const t = d3_transition().duration(500);
    const line = d3_line()
      .curve(d3_curveBasis)
      .x((d, i) => x(i))
      .y(d => y(d));
    // Update domains based on data.
    x.domain(d3_range(props.data.length));
    y.domain([0, d3_max(props.data)]);
    // Update svg width and x range according to props.
    d3_select('#svg').attr('width', props.outerWidth);
    x.range([0, props.innerWidth]);
    // Update axes.
    svg.select('.x.axis')
      .transition(t)
      .call(this.xAxis);
    svg.select('.y.axis')
      .transition(t)
      .call(this.yAxis);
    // Render the bars.
    const bars = svg.select('#bars').selectAll('.bar').data(props.data);
    bars.enter().append('rect')
      .attr('class', 'bar')
      .attr('x', props.innerWidth)
      .attr('y', d => y(d))
      .attr('width', 0)
      .attr('height', d => props.innerHeight - y(d))
      .attr('fill', 'steelblue')
      .transition(t)
        .attr('x' (d, i) => x(i))
        .attr('width', x.bandwidth() - 1);
    bars.transition(t)
      .attr('x', (d, i) => x(i))
      .attr('y', d => y(d))
      .attr('width', x.bandwidth() - 1)
      .attr('height', d => props.innerHeight - y(d));
    bars.exit()
      .transition(t)
      .attr('x', props.innerWidth)
      .attr('width', 0)
      .remove();
    // Render line.
    svg.select('#main-line')
      .datum(props.data)
      .transition(t)
      .attr('transform', 'translate(' + (x.bandwidth() / 2) + ',0)')
      .attr('d', line);
  }
  normalizeProps (props) {
    const margin = { top: 10, right: 10, bottom: 20, left: 25 };
    return Object.assign({}, props, {
      innerHeight : props.size[1] - margin.top - margin.bottom,
      innerWidth  : props.size[0] - margin.left - margin.right,
      margin      : margin,
      outerHeight : props.size[1],
      outerWidth  : props.size[0],
    };
  }
}

The Chart class contains all of our d3 code. The render() method is called once on instantiation and creates the static elements such as the main svg node and needed scales and axes. The update() method includes the standard d3 enter-update-exit pattern. This approach was used to construct the distribution viewer and trigonometry visualization tools on this site.

Comments

No comments exist. Be the first!

Leave a comment