React Re-render Optimization

Judy Jeong
Apr 24 · 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.
Image for post
Image for post

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.

Image for post
Image for post

How to read Flamegraph:

Image for post
Image for post

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.

Image for post
Image for post

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.

Image for post
Image for post

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

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store