d-Threeact: How the Sift Science Console Team Made d3 and React the Best of Friends
A little less than a year ago, the Sift Science Console Team decided to migrate its Backbone and Marionette Views to ReactJS (see also our post on specific React tips we learned along the way). Among the many facets of a piece-by-piece migration like this was figuring out how best to manage (err…’reactify’) our d3 charts. There were already a couple good reads/listens on this topic—with very different views on the responsibilities of each library—which we found quite helpful in establishing our own approach.
Ultimately, we decided that our d3 code should be isolated from our React code and only be available to React via a simple React Component interface. This way, we could take advantage of React’s one-way data flow into the component but still let the powerful d3 take care of all things svg-related (layout, data-binding, transitions, etc). So our ideal <Chart />
component would look like:
<Chart type='' data={} options={} />
With this setup, we could plant this generic <Chart />
component all over our codebase, supplying just the type of chart we want to produce and the data we want to display, and let d3 take care of the rest. Also, the <Chart />
component would have no notion of state with regards to data!
Okay, so now let’s work backwards from our end goal to figure out how we’re going to get there!
The Chart Component
First, we need to create the Chart component and integrate d3’s key selection operations (enter
, update
, exit
) into React’s lifecycle methods. Luckily, as Nicolas Hery pointed out, that integration happens pretty naturally. Here’s a first pass at Chart.jsx
:
// chart.jsx module.exports = React.createClass({ propTypes: { type: React.PropTypes.string.isRequired, data: React.PropTypes.array.isRequired, options: React.PropTypes.object }, componentDidMount() { // create chart and do first data bind }, componentDidUpdate() { // update chart with new data }, componentWillUnmount() { // cleanup after chart }, render() { return ( <div className={'sift-chart ' + _.dasherize(this.props.type)}></div> ); } });
Note that we don’t actually need to remove the chart in componentWillUnmount
, since those DOM elements are being unmounted by React, but we’ll want to detach anything the chart is tied to.
SiftChartFactory
Okay, so now that we have a skeleton for our component, we need a way to create a chart out of our data given a chart type. This is an excellent use case for the Factory Pattern. Here’s our first pass at a simple SiftChartFactory:
// sift_chart_factory.js var MyAwesomeScatterPlot = require('./my_awesome_scatterplot'), MyEvenCoolerBarGraph = require('./my_even_cooler_bar_graph'); SiftChartFactory = function(type) { var newChart; // throw an error if the chart type doesn't exist if (typeof SiftChartFactory[type] !== 'function') { throw new Error(type + ' is not a valid Sift Chart!'); } newChart = new SiftChartFactory[type](); return newChart; }; // attach all chart types as static properties SiftChartFactory.MyAwesomeScatterPlot = MyAwesomeScatterPlot; SiftChartFactory.MyEvenCoolerBarGraph = MyEvenCoolerBarGraph; module.exports = SiftChartFactory;
Putting aside our individual chart constructors, we can flesh out the <Chart />
component a little more with what we want out of the SiftChartFactory
interface:
// Chart.jsx SiftChartFactory = require('./sift_chart_factory'); module.exports = React.createClass({ propTypes: { type: React.PropTypes.string.isRequired, data: React.PropTypes.array.isRequired, options: React.PropTypes.object }, componentDidMount() { // create chart and do first data bind this._chart = new SiftChartFactory( this.props.type, this.props.data, this.getDOMNode(), this.props.options ); }, componentDidUpdate() { // update chart with new data this._chart.update(this.props.data); }, componentWillUnmount() { // cleanup after chart this._chart.remove(); }, render() { return ( <div className={'sift-chart ' + _.dasherize(this.props.type)}></div> ); } });
So this suggests that each chart constructor should have an update
and a remove
method, as well as some initialization during the instantiation within componentWillMount
. Additionally, however, we can leverage a common d3 pattern to separate the initialization into a setup
portion and an enter
portion, where the former establishes chart dimensions and margins, and the latter can be thought of simply as an initial update
. Therefore, we can write a shared (and overwritable, if need be) initialize
method from within the factory where all non-data setup occurs:
// sift_chart_factory.js var MyAwesomeScatterPlot = require('./my_awesome_scatterplot'), MyEvenCoolerBarGraph = require('./my_even_cooler_bar_graph'); SiftChartFactory = function(type, data, node, options) { var newChart; // throw an error if the chart type doesn't exist if (typeof SiftChartFactory[type] !== 'function' || typeof SiftChartFactory[type].prototype.update !== 'function') { throw new Error(type + ' is not a valid Sift Chart!'); } // copy over shared prototype methods to the child chart prototype if (!SiftChartFactory[type].prototype.initialize)) { _.extend(SiftChartFactory[type].prototype, SiftChartFactory.prototype); } newChart = new SiftChartFactory[type](); newChart.initialize(data, node, options); return newChart; }; // initial d3 setup, like merging options and defaults, and setting chart dimensions, // common for all charts. imagine we've defined a `defaults` hash of default options. SiftChartFactory.prototype.initialize = function(data, node, opts) { var options = this.options = _.defaults(opts || {}, defaults); // set dimensions, translation offset for axes, etc. nothing related to data! // more or less taken from d3 BarChart Tutorial at https://bost.ocks.org/mike/bar/3/ this.height = options.height - (options.margin.top + options.margin.bottom); this.width = chartWidth - (options.margin.right + options.margin.left); this.xAxis = d3.svg.axis().orient(options.xaxis.orientation); this.yAxis = d3.svg.axis().orient(options.yaxis.orientation); // main chart svg width, height, and margins this.svg = d3.select(node).append('svg') .attr('width', this.width + options.margin.left + options.margin.right) .attr('height', this.height + options.margin.top + options.margin.bottom) .append('g') .attr('transform', 'translate(' + options.margin.left + ',' + options.margin.top + ')'); // setup axes positions only (scaling involves data and should be chart-specific) this.svg.append('g').attr('class', 'x axis') .attr('transform', 'translate(0, ' + this.height + ')'); this.svg.append('g').attr('class', 'y axis') .append('text').attr('transform', 'rotate(-90)'); // now make first data bind (update) via chart-specific update method this.update(data); }; SiftChartFactory.MyAwesomeScatterPlot = MyAwesomeScatterPlot; SiftChartFactory.MyEvenCoolerBarGraph = MyEvenCoolerBarGraph; module.exports = SiftChartFactory;
Okay, so leaving remove
from componentWillUnmount
empty for now, this means that each custom chart constructor needs only an update
method to comply with the SiftChartsFactory
interface. Sweet!
// MyAwesomeScatterPlot.js, custom chart constructor example MyAwesomeScatterPlot = function() {}; MyAwesomeScatterPlot.prototype.update = function(data) { // custom MyAwesomeScatterPlot update selection logic };
Example Time!!!!!!11
Here’s an example of Sift’s StackedBarChart
type, used here to show customers how many successful/unsuccessful requests they’ve made to our Events API. The StackedBarChart
is simply an (empty) constructor with a custom update
method (we’ll leave the d3 implementation details out, since this post is much more about React integration):
To finish off this section, here is a rough example of what the parent component might look like for the chart above, simply passing the appropriate data to the <Chart />
component:
// parent component to <Chart /> module.exports = React.createClass({ getInitialState() { return { errorsOnly: false } }, onToggleErrorsOnly() { this.setState({ errorsOnly: !this.state.errorsOnly }); }, _aggregateAppropriateData() { if (this.state.errorsOnly) { // logic to return errors only data return errorsOnlyData; } // logic to return all data return totalData; }, render() { return ( <h2>Events API Activity</h2> {/* ...other stuff... */} <input type='checkbox' onChange={this.onToggleErrorsOnly} value={this.state.errorsOnly} /> <Chart type='StackedBarChart' data={this._aggregateAppropriateData()} options={{ height: 400, width: 600 }} /> ); } });
The animation is left for d3 to handle—something it does very well—via its selection.transition. All we have to do in React is pass it the data! The parent component handles the state of whether to pass errors-only data or total data. So easy!!
Adding event handlers
Okay, so the previous chart example is fine and all, but let’s take it a step further and provide some user interaction within the chart. Unfortunately, since we are not treating each
in our bar chart as a React component, we can’t take advantage of the nice abstractions React provides, i.e. <rect onClick={myClickHandler} />
. Let’s first figure out how we can integrate event listeners with our current setup, and then we can discuss how to implement them.
Integrating into the SiftChartFactory
Each chart type might optionally have certain hover or click interactions, etc, which should be bound as the component is mounted and unbound before the element leaves the DOM. The binding can happen as part of the factory’s common initialize
method, just after the initial update
—easy enough. The unbinding can be part of the aforementioned empty remove
method invoked in the <Chart />
component’s componentWillUnmount
method, which can also be refactored into a factory method! Our SiftChartFactory
becomes:
// SiftChartFactory.js // ... // ... SiftChartFactory.prototype.initialize = function(data, node, opts) { // ... // ... this.update(data); if (typeof this._addListeners === 'function') { this._addListeners(); } }; SiftChartFactory.prototype.remove = function() { if (typeof this._removeListeners === 'function') { this._removeListeners(); } };
Each chart constructor is now simply responsible for one required method (update
) and two optional methods (_addListeners
and _removeListeners
). Now we’re getting somewhere! Let’s take our previous example and add a tooltip to display an individual bar’s event count on hover.
Binding Mouse Events and Rendering a Tooltip
Making a tooltip is pretty tricky given our desired separation of responsibilities, because the mouse event needs to be registered in d3, but dynamically positioning and rendering a tooltip dialog is a lot easier in React. This is where our <Chart />
options prop comes in handy. Let’s add a renderTooltip
method to the parent component that takes in data and coordinates and returns the JSX for our desired tooltip, and then pass that method to our <Chart />
component:
// parent component to <Chart /> module.exports = React.createClass({ getInitialState() { return { errorsOnly: false } }, onToggleErrorsOnly() { this.setState({ errorsOnly: !this.state.errorsOnly }); }, _aggregateAppropriateData() { if (this.state.errorsOnly) { // logic to return errors-only data return errorsOnlyData; } // logic to return all data return totalData; }, // callback passed to Chart component // @param {object} intervalData - the event counts for the hovered bar group, of the shape: // { // x: <timestamp>, // y: [ // {y0: <bar1-begin-height>, y1: <bar1-end-height>}, // {y0: <bar2-begin-height>, y1: <bar2-end-height>}, // ... // ] // } // @param {object} translateCoords - for positioning the tooltip correctly renderTooltip(intervalData, translateCoords) { var style = { transform: 'translate3d(calc(' + translateCoords.x + 'px - 50%), ' + translateCoords.y + 'px, 0)' }; return ( <div className='sift-chart-tooltip' style={style}> <p>{this._hourTooltipHelper(intervalData.x)}</p> {!this.state.errorsOnly ? <p>events {intervalData.y[1].y1 - intervalData.y[0].y0}</p> : null} <p>errors {intervalData.y[0].y1}</p> </div> ); }, render() { return ( <h2>Events API Activity</h2> {/* ...other stuff... */} <input type='checkbox' onChange={this.onToggleErrorsOnly} value={this.state.errorsOnly} /> <Chart type='StackedBarChart' data={this._aggregateAppropriateData()} options={{ height: 400, tooltip: this.renderTooltip, // component methods are auto-bound to the component! :+1: width: 600 }} /> ); } });
Our <Chart />
component needs to be updated to include a container within which we want to render the tooltip:
// Chart.jsx SiftChartFactory = require('./sift_chart_factory'); module.exports = React.createClass({ // ... // ... componentWillUnmount() { // cleanup after chart this._chart.remove(); }, render() { return ( <div classname={'sift-chart ' + _.dasherize(this.props.type)}> <div classname='chart-tooltip'></div> </div> ); } });
The awesome thing about data binding in d3 is that the data is actually bound to the DOM element. It’s not stored in some jQuery DOM element wrapper abstraction; you can get the appropriate data for an element simply by calling document.querySelector(<selector>).__data__
. That means that since d3 event handlers are called with the DOM element as the context, we should have everything we need in our mouseover
listener.
So let’s update StackedBarChart.js
:
// StackedBarChart.js StackedBarChart = function() {}; StackedBarChart.prototype.update = function(data) { // custom StackedBarChart update selection logic }; StackedBarChart.prototype._addListeners = function() { if (this.options.tooltip) { // use d3 event bindings for hover this.svg.on('mouseover', _.partial(_onMouseOver, this.options)); this.svg.on('mouseout', _.partial(_onMouseOut)); } }; StackedBarChart.prototype._removeListeners = function() { if (this.options.tooltip) { this.svg.on('mouseover', null); this.svg.on('mouseout', null); } }; // callback in d3 is invoked with DOM element as current context! _onMouseOver = function(options) { var barGroup, intervalData; // crawl the DOM appropriately to find our tooltip node in which // to render our React tooltip component. this.tooltipNode = this.parentNode.parentNode.children[0]; // event delegation, d3-style. set one listener on the entire chart // as opposed to each individual `.bar` (where each <rect> in the // StackedBarChart has class 'bar') , which could be 100 for a stacked bar // chart, 1000 if you have several stacked bar charts on a page if (d3.select(d3.event.target).classed('bar')) { // get bound data from _actual_ DOM element, // in this case the parent of each group of stacked bars barGroup = d3.event.target.parentNode; intervalData = barGroup.__data__; // logic to correctly position the tooltip... // ... // ... // now call the passed renderTooltip method with the data and translation coords! React.render(options.tooltip(intervalData, {x: transX, y: transY}), this.tooltipNode); }; _onMouseOut = function() { // event delegation again if (d3.select(d3.event.target).classed('bar')) { React.unmountComponentAtNode(this.tooltipNode); } };
Here’s what our final version of this chart looks like:
This approach definitely blurs the lines between React/d3 responsibilities by actually calling React.render
from within our d3 code. But it still feels very Reactive because our tooltip method is defined by the parent component and passed into our d3 code as a prop in the same way that we pass callbacks to other child components. Our StackedBarChart
remains very reusable; another parent component with completely different data can call it with a completely different tooltip structure, and the d3 code will simply bind/unbind listeners and call the custom tooltip function with the appropriate data and positioning.
Not all interactions will be so involved. In the actual StackedBarChart
used in the Sift Science Console, we also pass a click handler prop, but without rendering anything new, all we need to do is invoke it with the right data. After implementing the tooltip, that one’s easy!
These are the kinds of cool projects and innovative solutions that our engineers work on every day. Interested in fighting digital fraud with the rest of the Sift Scientists? Join our team!
13 Comments
May 20, 2015 at 9:25 am
sounds cool 🙂 Are you going to open source this?
May 20, 2015 at 6:51 pm
Very possibly, but only after we add more (~10?) chart types!
May 21, 2015 at 6:35 pm
Let the community help you build this charts 🙂
May 23, 2015 at 12:17 pm
I second with Luis rudge, let us help you building more chart types! Open sourcing sooner than later will be easier as the community will get a handle of how the code gets changed.
May 20, 2015 at 3:31 pm
Thanks Noah – great stuff.
May 22, 2015 at 6:32 pm
The second-most elusive topic in web development for me right now. I vote a github repo be initiated immediately. I’ll even help set it up lol
May 23, 2015 at 6:05 am
Guys come on! Don’t post this and then not share the code… this is 2015!
May 23, 2015 at 12:52 pm
Great read. It would be of great help if you open sourced this!
May 23, 2015 at 4:56 pm
Please open source this and let us help you work on it. We’re a small to medium sized company currently using Highcharts for all our projects, and are looking for alternatives now that we have moved to react. This may just what we may be looking for! 🙂
May 24, 2015 at 12:38 am
Fucking epic charts. We need codez!!
May 27, 2015 at 5:46 am
come on guy, show me your code~~
July 24, 2015 at 8:48 pm
What if
_aggregateAppropriateData
was polling and performing AJAX calls to retrieve data. Would the current logic still stand or would you have to alter?April 12, 2016 at 4:03 am
Here is the the tutorial series to integrate React & D3. Step by step instruction.
https://www.adeveloperdiary.com/react-js/create-reusable-charts-react-d3-part1/
Demo:
https://adeveloperdiary.github.io/react-d3-charts/02_Admin_Dashboard/part4/index.html