Why Did This React Component Re-render?

By Eric Lathrop on

I ran into a head-scratching problem with a pure React component where it was re-rendering when it shouldn't. The component wrapped up a 3rd party script which put a button on my site. The problem was that when the button was clicked, some of my state changed so I could show a loading spinner. That state change made my component re-render, which broke the 3rd party script by re-initializing it.

The Setup

This is roughly how the component looked:

export default class ExternalLibraryButton extends React.PureComponent {
  componentDidUpdate() {
    this.callExternalLibrary();
  }

  callExternalLibrary() { /* ... */ }

  render() { /* ... */ }
}

Notice that it inherits from React.PureComponent, meaning it should only re-render when the props change.

This component was used like this:

<ExternalLibraryButton
  env={session.get("environment")}
  onAuthorize={this.onAuthorize}
  payment={this.onCreatePayment}
  style={{ size: "medium", shape: "rect" }} />

Do you see the problem? I didn't! All of the props shouldn't be changing. env comes from a configuration file, onAuthorize and payment are bound to this in the local constructor, and style is a hard-coded value.

Exposing The Problem

I updated componentDidUpdate to log any prop changes:

componentDidUpdate(prevProps) {
  Object.keys(this.props).forEach(key => {
    if (this.props[key] !== prevProps[key]) {
      console.log(key, "changed from", prevProps[key], "to", this.props[key]);
    }
  });
  this.callExternalLibrary();
}

The log message revealed the culprit:

style changed from Object { size: "medium", shape: "rect" } to Object { size: "medium", shape: "rect" }

The objects look the same, but are in fact, separate objects with the same values. Try this in your browser console:

> var a = {};
undefined
> var b = {};
undefined
> a === b;
false

The problem is that every time the parent component of ExternalLibraryButton is rendered, it creates a brand-new style prop. The default behaviour of React.PureComponent only does shallow equality checks, which fail and cause the component to re-render.

The Solution

The solution is to implement a custom shouldComponentUpdate, which does deep equality checks, for at least the style prop, if not all of them. The reason you don't want to do deep equality checks by default everywhere, is that they're slow, so you only do them on a case-by-case basis.

If you have Lodash in your project you could use isEqual like:

shouldComponentUpdate(nextProps) {
  return !_.isEqual(this.props, nextProps);
}

I didn't have a library with a deep equality check already, and I didn't want to add a new dependency, so my version ended up a bit more complicated:

shouldComponentUpdate(nextProps) {
  var oldKeys = Object.keys(this.props);
  var newKeys = Object.keys(nextProps);
  if (!areArraysShallowEqual(oldKeys, newKeys)) {
    return true;
  }
  for (var i = 0; i < oldKeys.length; i++) {
    var key = oldKeys[i];
    if (!areCustomEqual(this.props[key], nextProps[key])) {
      return true;
    }
  }
  return false;
}

function areArraysShallowEqual(a, b) {
  if (a.length !== b.length) {
    return false;
  }
  a.sort();
  b.sort();
  for (var i = 0; i < a.length; i++) {
    if (a[i] !== b[i]) {
      return false;
    }
  }
  return true;
}

function areCustomEqual(a, b) {
  if (a === b) {
    return true;
  }

  if (typeof a === "object" && typeof b === "object") {
    return JSON.stringify(a) === JSON.stringify(b);
  }

  return false;
}