Quickly integrate any D3 code in your React project with Blackbox Components
This section teaches you the mental models of blackbox components and builds them up through class-based components. If you don't care about the details, you can jump ahead to using them with React Hooks.
Blackbox components are the quickest way to integrate D3 and React. You can think of them as wrappers around D3 visualizations.
With the blackbox approach, you can take any D3 example from the internets or your brain, wrap it in a React component, and it Just Worksโข. This is great when you're in a hurry, but comes with a big caveat: You're letting D3 control some of the DOM.
D3 controlling the DOM is okay, but it means React can't help you there. That's why it's called a Blackbox โย React can't see inside.
No render engine, no tree diffing, no dev tools to inspect what's going. Just a blob of DOM elements.
Okay for small components or when you're prototyping, but I've had people come to my workshops and say "We built our whole app with the blackbox approach. It takes a few seconds to re-render when you click something. Please help"
๐ค
Here's how it works:
- React renders an anchor element
- D3 hijacks it and puts stuff in
You manually re-render on props and state changes. Throwing away and rebuilding the entire DOM subtree on each render. With complex visualizations this becomes a huge hit on performance.
Use this technique sparingly.
A quick blackbox example - a D3 axis
Let's build an axis component. Axes are the perfect use-case for blackbox components. D3 comes with an axis generator bundled inside, and they're difficult to build from scratch.
They don't look difficult, but there are many tiny details you have to get just right.
D3's axis generator takes a scale and some configuration to render an axis for us. The code looks like this:
const scale = d3.scaleLinear().domain([0, 10]).range([0, 200])const axis = d3.axisBottom(scale)d3.select("svg").append("g").attr("transform", "translate(10, 30)").call(axis)
You can try it out on CodeSandbox.
If this code doesn't make any sense, don't worry. There's a bunch of D3 to learn, and I'll help you out. If it's obvious, you're a pro! This book will be much quicker to read.
We start with a linear scale that has a domain [0, 10]
and a range
[0, 200]
. Scales are like mathematical functions that map a domain to a
range. In this case, calling scale(0)
returns 0
, scale(5)
returns 100
,
scale(10)
returns 200
. Just like a linear function from math class โย y =
kx + n.
We create an axis generator with axisBottom
, which takes a scale
and
creates a bottom
oriented axis โ numbers below the line. You can also change
settings for the number of ticks, their sizing, spacing, and so on.
Equipped with an axis
generator, we select
the svg
element, append a
grouping element, use a transform
attribute to move it 10
px to the right
and 30
px down, and invoke the generator with .call()
.
It creates a small axis:
Play around with it on Codesandbox.
Change the scale type, play with axis orientation. Use .ticks
on the axis to
change how many show up. Have some fun ๐
A quick blackbox example - a React+D3 axis
Now let's say we want to use that same axis code but as a React component. The simplest way is to use a blackbox component approach like this:
class Axis extends Component {gRef = React.createRef()componentDidMount() {this.d3render()}componentDidUpdate() {this.d3render()}d3render() {const scale = d3.scaleLinear().domain([0, 10]).range([0, 200])const axis = d3.axisBottom(scale)d3.select(this.gRef).call(axis)}render() {return <g transform="translate(10, 30)" ref={this.gRef} />}}
So much code! Worth it for the other benefits of using React in your dataviz. You'll see ๐
We created an Axis
component that extends React's base Component
class. We
can't use functional components because we need lifecycle hooks.
Our component has a render
method. It returns a grouping element (g
) moved
10px to the right and 30px down using the transform
attribute. Same as
before.
A React ref saved in this.gRef
and passed into our <g>
element with ref
lets us talk to the DOM node directly. We need this to hand over rendering
control to D3.
The d3render
method looks familiar. It's the same code we used in the vanilla
D3 example. Scale, axis, select, call. Only difference is that instead of
selecting svg
and appending a g
element, we select the g
element rendered
by React and use that.
We use componentDidUpdate
and componentDidMount
to keep our render up to
date. Ensures that our axis re-renders every time React's engine decides to
render our component.
That wasn't so bad, was it?
You can make the axis more useful by getting positioning, scale, and orientation from props. We'll do that in our big project.
Practical exercise
Try implementing those as an exercise. Make the axis more reusable with some carefully placed props.
Here's my solution, if you get stuck ๐ https://codesandbox.io/s/5ywlj6jn4l
A D3 blackbox higher order component โ HOC
After that example you might think this is hella tedious to implement every time. You'd be right!
Good thing you can abstract it all away with a higher order component โย a HOC. Now this is something I should open source (just do it already), but I want to show you how it works so you can learn about the HOC pattern.
Higher order components are great when you see multiple React components sharing similar code. In our case, that shared code is:
- rendering an anchor element
- calling D3's render on updates
With a HOC, we can abstract that away into a sort of object factory. It's an old concept making a comeback now that JavaScript has classes.
Think of our HOC as a function that takes some params and creates a class โ a React component. Another way to think about HOCs is that they're React components wrapping other React components and a function that makes it easy.
A HOC for D3 blackbox integration, called D3blackbox
, looks like like this:
function D3blackbox(D3render) {return class Blackbox extends React.Component {anchor = React.createRef()componentDidMount() {D3render.call(this)}componentDidUpdate() {D3render.call(this)}render() {const { x, y } = this.propsreturn <g transform={`translate(${x}, ${y})`} ref={this.anchor} />}}}
You'll recognize most of that code from earlier.
We have componentDidMount
andcomponentDidUpdate
lifecycle hooks that call
D3render
on component updates. render
renders a grouping element as an
anchor with a ref so D3 can use it to render stuff into.
Because D3render
is no longer a part of our component, we have to use .call
to give it the scope we want: this class, or rather this
instance of the
Blackbox
class.
We've also made some changes that make render
more flexible. Instead of
hardcoding the translate()
transformation, we take x
and y
props.
{ x, y } = this.props
takes x
and y
out of this.props
using object
decomposition, and we used ES6 string templates for the transform
attribute.
Consult the ES6 cheatsheet for details on the syntax.
Using our new D3blackbox
HOC to make an axis looks like this:
const Axis = D3blackbox(function () {const scale = d3.scaleLinear().domain([0, 10]).range([0, 200])const axis = d3.axisBottom(scale)d3.select(this.anchor).call(axis)})
You know this code! We copy pasted our axis rendering code from before, wrapped
it in a function, and passed it into D3blackbox
. Now it's a React component.
Play with this example on Codesandbox, here.