React Re-render Optimization

Judy Jeong
Apr 24, 2020 · 6 min read

Composer is a feature that Hootsuite supports allowing users to create and publish messages to social media. Back in 2019, we had customers reporting performance issues on Composer, that’s when we noticed a lot of unnecessary re-renders were triggered. By default, React components automatically update when their props/state change and these updates propagate down the virtual DOM tree. As the size of our application grew, this was problematic as wasted re-renders pushed the CPU hard. Note that Javascript is a single threaded language, so if the CPU has any blocking tasks, the user would see noticeable slowdown and reduced interactivity. We had to commit time to claim back these wasted CPU cycles and make Composer more reliable and interactive again. In this article, I’m going to share how we detected these wasted re-renders and a few techniques we used to eliminate them.

Measurement

First, we need a way to measure performance. We are going to use the React Profiler plugin which comes in handy when diagnosing performance bottlenecks. Profiler will collect information of each re-rendered component including render time and reasons why each rendered.

To spot unnecessary re-renders, simply click on “Highlight updates when components render” option in the Profiler setting. If you perform some user actions, you should see updated components are highlighted on the screen.

Composer — before the fix

This is what our application looked like. When a user types in a text box, almost all the components you see on the screen are re-rendered. Let’s now deep dive into what’s causing these re-renders by analyzing a Flamegraph, a visualization of the profiling data.

To generate a Flamegraph:

  1. Navigate to the page you want to analyze.
  2. Open up Profiler under Developer Tools.

3. Make sure “Record why each component rendered while profiling” is checked.

4. Click the “Start profiling” recording button.

5. Perform actions you want to profile

6. Click the recording button again to stop.

The Profiler will now generate a Flamegraph that represents the performance data. The following Flamegraph captures the action performed in Composer.

How to read Flamegraph:

  • Top right corner, you’ll see a small bar chart that represents commits. Heights and colours will indicate if the renders took a lot of time. Green indicates less time and yellow more time.
  • Selecting a particular commit brings you a graph view of components involved.
  • In the Flamegraph, each bar represents a React component. Note, grey bars indicate the corresponding components didn’t re-render. If you click one, a list of rendered time for this component shows up on the right panel. Under the “why did this render” section, you will see what causes this component to re-render. This is a good starting point.

Identify where long lists of components are rendered. Then ask whether re-rendering was necessary. From the above Composer example, let’s focus on two separate components, MediaPicker and MessageEditText.

MediaPicker is responsible for attaching uploaded or selected files in Composer and MessageEditText is a component where users type texts. As you can see on the Flamegraph, both components re-rendered. We know the only action that was performed was typing text in MessageEditText, so should MediaPicker be re-rendered? No, because media content has not changed. So we should prevent this re-render if possible.

Techniques

Now you know which components are taking up the CPU cycles, here are few techniques we used to fix unnecessary re-renders:

  1. Don’t pass a complex data object as a prop.

This was the biggest problem we had. We have a complex data object model that contains a mix of simple data like id, type, and date, object types, and arrays of objects. This entire data object was passed from the very top of the DOM tree all the way down to child components. It was putting a lot of strain on performance. Even a component, which needed a single value, received the entire object. The fix was simple for this case. Pass in only the relevant value. For example, if a component only uses the object id, just pass the id. But what if the value is likely to change frequently? Wouldn’t it cause a lot of renders? Yes. To address this problem, we reorganized how components access the data object. Instead of passing it as props, each component that needs the data object is now connected to the store directly and watches for a change.

2. Use React.PureComponent

Extend React.PureComponent for components with simple props and state. A difference between Component and PureComponent is that PureComponent does a shallow comparison of current props/state with previous props/state. So if the component receives the same props/state as the last render, it won’t trigger a re-render. Note, for array or object type, shallow comparison doesn’t compare the actual values, but references. This could cause problems if you have mutable props/state. So try to treat props/state as immutable objects.

class Parent extends React.Component {
constructor() {
super()
this.state = {items:[]}
this.handleSelect = () => {
this.state.items.push("item")
}
}
...

render() {
return (
<Child onSelect={this.handleSelect} items={this.state.items}/>
)
}
}
class Child extends React.PureComponent {
...
}

For example, the state “items” is updated with push() in the Parent every time onSelect() is called in the Child component. But this won’t trigger a re-render. Even though the content of the items has changed, it’s still the same instance. Because it’s the same instance, PureComponent will not trigger an update as the shallow comparison will return true.

class Parent extends React.Component {
constructor() {
super()
this.state = {items:[]}
this.handleSelect = () => {
this.setState(prevState => (
{ items: [... prevState.items, "item"] }
)
}
}
...

render() {
return (
<Child onSelect={this.handleSelect} items={this.state.items}/>
)
}
}
class Child extends React.PureComponent {
...
}

To prevent this from happening, we can use setState() to create a new instance instead of mutating “items”.

Note: You can use functional components with React.memo if there’s no need for internal state. It has similar behaviours as React.PureComponent. To learn more, React.memo.

3. shouldComponentUpdate()

shouldComponentUpdate(nextProps, nextState){
return true;
}

Alternative to using PureComponent, you can override shouldComponentUpdate() function. It’s a better option when you want selective re-renders. Before React compares current and previous values, it’s going to ask shouldComponentUpdate() whether it should update or not. By default, it returns true. If return false, it will skip comparison and no render happens for this component. You can use this function to compare the actual values of array or object type props.

4. Avoid inline function definitions inside a render function.

Inline functions are often easy to read and write, but with one caveat: Each time render() is called, a new instance of the function is allocated and the component will always re-render. PureComponent won’t help, because the two different instances of a function are never equal meaning the shallow comparison will always return false.

class Parent extends React.Component {
render() {
return (
<Child onSelect={() => ... }/>
)
}
}
class Child extends React.PureComponent {
...
}

For example, we have a Parent component and Child PureComponent. Each time the Parent renders, it generates a new function as the value of the prop causing the Child to re-render as well. Thus fail to take advantage of PureComponent.

class Parent extends React.Component {
handleSelect = () => { ... }

render() {
return (
<Child onSelect={this.handleSelect}/>
)
}
}
class Child extends React.PureComponent {
...
}

You can fix it by declaring it as a class method. Now the shallow comparison will return true, preventing Child’s wasted re-render.

After the Fix

We were able to eliminate a significant number of wasted re-renders by applying techniques I mentioned above. Now when a user types, only the component responsible for text content is updated.

Composer — after the fix

Here is the corresponding Flamegraph. Commits that took more than 16ms are now roughly under 3ms.

Hopefully you can apply those techniques to improve performance on your application as well. Don’t forget, measuring performance should always be the first thing you do before optimizing. Good luck!

Hootsuite Engineering

Hootsuite's Engineering Blog