A React Rendering Misconception
React's diffing algorithm allows developers to author user interfaces in a declarative way, without worrying about how to handle updates when the backing data changes. When a component is updated, React only applies the parts that changed to the DOM. This results in fluid interface transitions, devoid of flickering.
When I was learning React, an assumption I made was that a component will only be re-rendered if something it depends on changes, e.g. a passed in property or the component's own state is updated. I was surprised to learn this is not true.
Part of the misconception was that I didn't understand that rendering a component and updating the DOM for that component are two separate steps in the lifecycle. The component has to be re-rendered in order for the diffing algorithm to compare it to the previous output. If the output is different, it will update the DOM accordingly. Re-rendering often isn't necessarily a bad thing; components are typically small, focused, and cheap. In my case the component was not cheap, and it was being re-rendered a lot.
As an example, let's build a chart with, I dunno, the historical daily use of emoji and reactions in thoughtbot's Slack. We want some amount of interaction, so let's draw a vertical line on the chart that matches the mouse location, and show the values of the data at the location as part of the legend. Glossing over the details of using D3 with React, an incomplete example of such a chart might look like:
class Chart extends React.Component {
// ...
updateMouseLocation(event) {
this.setState({
mouseLocation: extractMouseLocation(event),
});
}
render() {
const { data, mouseLocation, scales } = this.state;
return (
<div>
<svg onMouseMove={e => this.updateMouseLocation(e)}>
<Axis scale={scales.y} orient="left" />
<Axis scale={scales.x} orient="bottom" />
<Area data={data.emoji} scales={scales} />
<Area data={data.reactions} scales={scales} />
<MouseLine mouseLocation={mouseLocation} />
</svg>
<Legend data={data} scales={scales} mouseLocation={mouseLocation} />
</div>
);
}
}
Our chart component represents an SVG element, and it delegates the drawing of
all the chart pieces to other components. The Axis
and Area
components lean
on D3's capabilities to convert the data into SVG elements. We listen for the
mouse move event on the SVG and update our state with the new position, which is
pushed down to the MouseLine
and Legend
components. The scales
object has
the information about our minimum and maximum values for both axes and the size
of the SVG. It's the workhorse for transforming domain values into coordinates
on the chart.
We'll also include a bit of code in the Axis
and Area
components to track
how often they are rendered. We'll use a single counter and each render will
increment it. This happens outside of React's world, to avoid affecting our
experiment.
// global for ease of example
let renders = 0;
// in Axis and Area
render() {
renders += 1;
document.getElementById("renders").innerText = `renders: ${renders}`;
}
When the data is loaded and the chart is initially drawn, we expect the render
count to be 4
- one for each of our two Axis
and Area
components. If we
move our mouse over the graph, we'll see a vertical line and some details about
the data at the point.
We also see the number of renders skyrocket. Every time the mouse move event
fires for our SVG, the Chart
component updates its state, which triggers a
re-render of itself and all of its children. The underlying data we're
visualizing isn't changing, so the outputs of the Axis
and Area
components
remain the same and their DOM elements are unchanged. However, we are spending
a ton of CPU cycles to determine that. If our chart was complex enough, we
could even see it become sluggish as the CPU can't keep up with the amount of
work we're throwing at it. We need to fix this, but how?
React's components have a defined lifecycle. During an update, the
shouldComponentUpdate
method will be called before the component is
rendered. If the return value of this method is false
, our component won't be
rendered.
Now we need to determine when our component should be rendered - if we always
return false
, we'll never see anything, so we need to be little bit smarter
about it. In our example, Axis
and Area
are displaying data they receive
from their properties and the only reason their outputs would change is the
underlying data changes. The shouldComponentUpdate
method's parameters are
objects representing what the next properties and state will be. We could do
a simple comparison to determine if our components should re-render:
shouldComponentUpdate(nextProps, nextState) {
// compare current and next props and state
}
However, React has already accounted for our use case. It provides a
PureComponent
base class that comes with a shouldComponentUpdate
implementation that does a shallow comparison of the props and state. Exactly
what we want. We can update our components to take advantage of it:
class Axis extends React.PureComponent { ... }
class Area extends React.PureComponent { ... }
With those changes in-place, we can see that moving our mouse around the chart
no longer causes our Axis
and Area
components to re-render - our count stays
at 4
:
In summary, we've learned about React's lifecycle hooks, we've taken advantage of them to prevent some expensive components from re-rendering, and we've saved precious CPU cycles on our users' computers.