React.js pure render performance anti-pattern
Pure render optimized React components can be extremely performant but it requires users to treat their data as immutable for it to work properly. Unfortunately due to nature of Javascript it can be quite challenging sometimes.
tl;dr. The anti-pattern is creating new arrays, objects, functions or any other new identities during render or in Redux connect(mapState).
Pure render?
With React.js pure render I mean components that implement the shouldComponentUpdate method with shallow equality checks. Examples of this are the React.PureComponent, PureRenderMixin, recompose/pure and many others.
Why?
It is probably the single most significant performance optimization you can do in React. It is also basically what ClojureScript wrappers for React can do by default and is why they can claim to be faster than vanilla React. For it to work immutable data structures must be used for the state because it makes it really cheap to check whether it is necessary to re-render the components. With mutable data deep equality checks are required which can be really expensive. In ClojureScript this is easy because everything is immutable always. In Javascript not so much.
To be fair React is reasonably fast even without the pure render optimization but especially with Javascript based animations (ex. react-motion) where the components can be rendered hundreds of times in a second or with big components such as editable tables with hundreds cells the optimization can become quite essential. Also mobile devices with slower CPUs will see a bigger benefit from this.
The anti-pattern
Few months back I wrote table editor which was used to import users from spreadsheets that our customers sent to us. The sheets could easily contain over 500 users. In a fairly top level component I had written something like this:
class Table extends PureComponent {
render() {
return (
<div>
{this.props.items.map(i =>
<Cell data={i} options={this.props.options || []} />
)}
</div>
);
}
}
In reality this was much more complicated. The Cell component was even more complicated and the app rendered multiple cells for each user. So there were thousands Cell elements in the app.
After a hacking a while I loaded 500 users into the app and I tried to edit one cell and the edit operation took over a second on my fairly performant PC to complete! After some quick console.log() debugging I saw that almost my whole app was being re-rendered for the small one cell change. How it can be? I was using Redux, deep freezing my state and living the immutable dream!
After hours of scratching my head I realized that one of the changes I recently made was the default array value for the options prop:
this.props.options || []
You see the options array was passed deep down in the Cell elements. Normally this would not be an issue. The other Cell elements would not be re-rendered because they can do the cheap shallow equality check and skip the render entirely but in this case the options prop was null and the default array was used. As you should know the array literal is the same as new Array() which creates a new array instance. This completely destroyed every pure render optimization inside the Cell elements. In Javascript different instances have different identities and thus the shallow equality check always produces false and tells React to re-render the components.
The fix was easy:
const default = [];
class Table extends PureComponent {
render() {
return (
<div>
{this.props.items.map(i =>
<Cell data={i} options={this.props.options || default} />
)}
</div>
);
}
}
Now the edit operation took only few dozen milliseconds! The defaultProps class property could also have been used.
Functions create identities too
Functions created in render will also have exactly the same issue. Too often following code is written
class App extends PureComponent {
render() {
return <MyInput
onChange={e => this.props.update(e.target.value)} />;
}
}
or
class App extends PureComponent {
update(e) {
this.props.update(e.target.value);
}
render() {
return <MyInput onChange={this.update.bind(this)} />;
}
}
In both cases a new function is created with a new identity. Just like with the array literal. You need to bind the function early:
class App extends PureComponent {
constructor(props) {
super(props);
this.update = this.update.bind(this);
}
update(e) {
this.props.update(e.target.value);
}
render() {
return <MyInput onChange={this.update} />;
}
}
This is bit repetitive though. Other options are to use React.createClass() which automatically binds all methods or to use arrow functions with Class Instance Fields proposal with Babel. There’s also this autobind decorator.
ESLint rule react/jsx-no-bind is a great tool for catching this.
Use Reselect in Redux connect(mapState)
Originally I didn’t think that Reselect , library which is even mentioned in the official Redux docs, would be that important because I would rarely write that expensive map state functions for Redux connect(). Was so wrong. It just wasn’t about expensive functions but object identities (surprise!) Consider following map state function:
let App = ({otherData, resolution}) => (
<div>
<DataContainer data={otherData} />
<ResolutionContainer resolution={resolution} />
</div>
);const doubleRes = (size) => ({
width: size.width*2,
height: size.height*2
});App = connect(state => {
return {
otherData: state.otherData,
resolution: doubleRes(state.resolution)
}
})(App);
In this case every time otherData in the state changes both DataContainer and ResolutionContainer will be rendered even when the resolution in the state does not change. This is because the doubleRes function will always return a new resolution object with a new identity. If doubleRes is written with Reselect the issue goes away:
import {createSelector} from “reselect”;const doubleRes = createSelector(
r => r.width,
r => r.height,
(width, height) => ({
width: width*2,
height: heiht*2
})
);
Reselect memoizes the last result of the function and returns it when called until new arguments are passed to it.
Conclusion
The anti-pattern is obvious when you realize it but it can still easily sneak in. The up-side of this is that if you screw up, like I have multiple times, it doesn’t break your app. It just performs bit slower and most of the time it doesn’t even matter but when it does I hope this article gives you some pointers on where to look.
Liked the article? Check out Lean Redux (shameless self promotion).