Building a chart with React and D3, which supports brushing (selectable charts)

If you search for code examples that make D3 charts work from within React, you’ll find a few general approaches, but none of them work well for features that require stateful D3 operations. For instance, one of the D3 features I find most compelling is “brushing”, which lets you select an area of the chart:

Generally, there are three approaches:

  1. Copy the DOM that D3 generates, and paste it into your React component, removing D3 entirely
  2. Insert a fake DOM layer (different from Virtual DOM) that does the above, but keeps D3 in (react-faux-dom does a good job at this1 )
  3. Use a library that purports to wrap D3 in React (e.g. react-d3 does this2 )
  4. Block React from updating at all, and let D3 manage it’s own state

Of the libraries that are supposed to do this for you, the only one that appears to support brushing is React-D3, but it does not appear to be actively maintained (no responses on tickets). At the time of writing, it doesn’t support React 15 (I investigated creating a pull request, but it is a surprisingly large library)

React-faux-dom is a good option (and actively maintained), but the author does not expect it to work with D3 state (i.e D3 wants to own it’s own state/props, you can’t follow the Flux pattern). The basic issue is that D3 creates and owns it’s own DOM elements, and maintains internal state for them, especially for brushes. Some of this can be extracted from D3 – e.g. people seem to have success with animations, but selectable charts are much harder.

Wrapping D3 in a non-updating React component is a clear winner, and matches the top Stackoverflow post3 so I’ll show how to do that here. As a final note, there do not seem to be any charting libraries that are “native” to React, and if there were, they would probably be lacking a lot of important features. If you try to do charts in a React application, you will most likely run into impedance mismatches between React and the charting library, no matter which library you choose.

To get React set up, the first thing we’ll need is to have React create a div, which we can later provide to D3 to draw it’s data. To make this work, we’ll size the div ahead of time, which prevents excessive page re-rendering.

"use strict";

var React = require('react');
var d3 = require('d3');

let margin = {top: 0, right: 10, bottom: 25, left: 10},
    width = 200 - margin.left - margin.right,
    height = 60 - margin.top - margin.bottom;

let histogramStyle = {
  "width": width,
  "height": (height + 15)
};

let Chart = React.createClass({
    render: function() {
    return (
      
); } });

Next, we want to block React from ever changing the DOM. We can do this by having “shouldComponentUpdate” always return false (normally it always returns true, but you can add mixins like the PureRenderMixin to check how much props change). Once we make this change, almost none of the React lifecycle methods get called. If you don’t do this, your D3 chart will continuously render overtop of itself (most likely in different random bits of the page).

let Chart = React.createClass({
  shouldComponentUpdate() {
    return false;
  },
  ..
})

Next we want to set up the page’s initial state. This includes many of the shared D3 objects, like the axes

let Chart = React.createClass({
  getInitialState: function() {         
    const data = this.props.data;    

    const x = d3.scale.linear()
            .range([width, 0]),
        y = d3.scale.linear()
            .range([height, 0]);
                         
    const 
      xAxis = d3.svg.axis()
        .scale(x)
        .orient("bottom");

    const area = d3.svg.area()
        .x((d) => x(d[0]))
        .y0(height)
        .y1((d) => y(d[1]));
            
    const xmin = 
      self.props.min !== undefined ? 
      self.props.min : 
      d3.min(data, (d) => d[0]);
    const xmax = 
      self.props.max !== undefined ? 
      self.props.max :
      d3.max(data, (d) => d[0]);
      
    x.domain([xmax, xmin]);
    y.domain([0, d3.max(data, (d) => d[1])]);            
            
    const brush = d3.svg.brush()
      .x(x)
      .on("brushend", this.brushend);
          
    return {
      facetHandler: this.props.facetHandler,
      x: x,
      y: y,
      xAxis: xAxis,
      area: area,
      brush: brush
    }  
  },
  ...
})

You can see here we’re defining a callback for when the brush changes. This allows us to call the parent class, update it’s state, and potentially get new data.

let Chart = React.createClass({
  brushed: function() {
    this.state.x.domain(
      this.state.brush.empty() ? 
        this.state.x.domain() : 
        this.state.brush.extent());
  },
  ...
})

Now we’re set up to make the chart. First thing we need to do is to render it on the initial load – this needs to happen after “render”, since that creates our DOM, but not too many events are called. Fortunately we can use componentDidMount, which gets called exactly once (first render). If you’re familiar with D3, this should look like a familiar technique, except that we’re going to save the top level D3 object on “this”. I’ve chosen not to use state at all, because we aren’t looking to trigger re-renders.

let Chart = React.createClass({
  componentDidMount: function() {
    const self = this;
    const data = this.props.data;

    this.svg = d3.select("#" + this.props.id).append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        .append("g")
        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

    this.svg.append("path")
       .datum(data)
       .attr("class", "area")
       .attr("d", this.state.area);
        
    this.svg.append("g")
       .attr("class", "x brush")
       .call(this.state.brush)
       .selectAll("rect")
       .attr("y", -6)
       .attr("height", height + 7);

    this.svg.append("g")
      .attr("class", "x axis")
      .attr("transform", "translate(0," + height + ")")
      .call(this.state.xAxis);                                    
  },
  ...
});

The final piece we’re missing is to handle updates to the D3 objects after we have a “brushend” event (i.e. the user highlighted part of the graph). The perfect event for this is “componentWillReceiveProps”, which happens after the new props are available (they come in as an argument), but before React decides to do nothing from our earlier changes.

In this function call, we can get the new data, then trigger D3 to re-render it’s DOM:

  componentWillReceiveProps: function(nextProps) {
    const data = nextProps.data;    

    // This is only needed if you want to re-set the y domain (e.g.
    // maybe you have more or less data in the chart now)    
    this.state.y.domain([0, d3.max(data, (d) => d[1])]);            

    this.svg
        .select("path")
          .datum(data)
          .attr("class", "area")
          .attr("d", this.state.area)
       .select("path")
          .datum(data)
          .attr("class", "area")
          .attr("d", this.state.area)
       .select("g")
          .attr("class", "x brush")
          .call(this.state.brush)
          .selectAll("rect")
          .attr("y", -6)
          .attr("height", height + 7);
  },
  ...
});

As you can see, we can trigger things in here like re-rendering the state of the axes. Now that we’ve done this, we can put together rich D3 charts orchestrated by React.

If you are starting from scratch, I found that to get this working, the easiest path was to write a D3 based chart, then put it all in componentDidMount. Once you do this, you can pull out the static objects one at a time into getInitialState, until you get the core of your chart. Once this is complete, it’s much simpler to split the update code out from the initial rendering.

  1. https://github.com/Olical/react-faux-dom []
  2. http://www.reactd3.org/ []
  3. http://stackoverflow.com/questions/21903604/is-there-any-proper-way-to-integrate-d3-js-graphics-into-facebook-react-applicat []

Leave a Reply

Your email address will not be published. Required fields are marked *