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;
}