← Back to index

A React Rendering Misconception

ยท Original Post

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.