10 tips for tuning React UI performance

Nobody likes a slow web UI. Fortunately, React gives you a number of ways to tune UI performance, from ways to optimize updates to the DOM to the ability to render and fetch simultaneously using the new concurrent mode.

10 tips for tuning React UI performance
Thinkstock

React remains the most popular JavaScript framework. This article covers the latest tips on wringing the most performance from the React framework, including functional components and the Suspense feature.

React works by maintaining an in-memory model of the view (often called a virtual DOM) that is used to determine if and when the actual DOM should be updated. Manipulating the actual DOM is expensive, so many performance improvements revolve around ensuring that changes to the DOM occur only when absolutely necessary.

We’ll look at several of these DOM-oriented techniques here, with differences for functional and class-based components, along with some more general tips.

shouldComponentUpdate

When writing class-based components, you can override the shouldComponentUpdate() lifecycle method. The purpose of this method is to explicitly declare whether the component requires re-rendering. To reiterate, rendering is the expensive part of the lifecycle wherein the actual DOM is updated. React only renders if a component’s props or state have changed, but sometimes you can skip even this, avoiding calling render at all.

The signature and action of shouldComponentUpdate is simple. Listing 1 has a basic example. The idea here is that you know your component and you can specify those conditions where it should and should not update. The method receives the incoming props and state as arguments. If the method returns true, the component will render, otherwise it will not.

Listing 1. shouldComponentUpdate() example

shouldComponentUpdate(nextProps, nextState) {
    if (this.props.significant !== nextProps.significant) {
      return true;
    }
    return false;
  }

Listing 1 deals with a prop, but the same procedure applies to state. We check if a property or state value that matters has changed and return true if so. This can be more involved if the state or props involved are more complex. If it is a simple shallow value comparison, then you can rely on the next tip, using the PureComponent as a shortcut.

PureComponent

If your component only requires a simple shallow comparison of props and states to determine go/no-go on the render decision, you can extend the PureComponent base class like so: class MyComponent extends React.PureComponent. This will do exactly this: If no change is detected in state and props via shallow comparison then render() will not be called.

The name PureComponent refers to a lack of side effects in the component, i.e., it is pure with respect to causing changes to its output only due to state or property changes.

useEffect

The preceding tips work only for class-based components. To achieve something similar with functional components, you can leverage a couple of functional component features: the useEffect hook and memo.

You can learn more about hooks here and in my previous article on React functional components. For the present discussion, we are interested in the specific feature of useEffect that allows for specifying that the effect runs only if certain variables have changed. 

useEffect is like the shouldComponentUpdate feature writ small, in that it allows for running certain (potentially expensive) code only if a variable has changed. You can see this in Listing 2.

Listing 2. useEffect example

const MyComponent = (props) => {
  useEffect(() => {
    console.info("Update Complete: " + props.significantVariable);
  }, [props.significantVariable]);
}

Listing 2 says if props.significantVariable has changed, then run the code. You can thereby avoid running the effect if it only needs to happen when the variable changes.

Memoize with React.memo

The next trick up the functional component’s sleeve is React.memo. memo is a higher order component, which means it wraps your component and adds to its behavior. In this case, memo allows for a functional component to cache, or “memoize,” its results if they are the same for the same props. Normally, a functional component will always render, regardless of the props being consistent or not.

To mimic the behavior of PureComponent with respect to props only, you can wrap your functional component as seen in Listing 3. This will check for changes to the props, but not the state. (Note this is different from PureComponent, which compares both props and state.) In Listing 3, if props.quote hasn’t changed, then the component will not re-render.

Listing 3. React.memo example (simple use)

const MyComponent = (props) => {
  return <span>props.quote</span>
}
export default React.memo(SomeComponent)

React.memo also allows a second argument, which is a function to check for equality:

export default React.memo(MyComponent, (oldProps, newProps) => {} );

This function receives the old and new props and lets you compare them in the way that makes sense for your use case. Note that this function should return true if the props are equal. Note that is the reverse of shouldComponentUpdate, which returns true if the component should update.

Windowing aka list virtualization

Now let’s turn our attention to a technique that applies to both functional and class components: windowing. If you have large datasets to display in lists (a table or list with thousands of rows) then you should look at “windowing” the data, which is to say, loading and displaying only a portion of the data at a time. This will prevent the large data from causing the UI to grind to a halt.

The react-window library is commonly used for this purpose.

Function caching

If you have expensive function calls, you should consider caching them. This can be done as a memoized cache (i.e., if the arguments are the same, the result is returned from cache), but the caching possibilities are guided by the function characteristics. There are cases where caching functions can avoid data fetching calls.

Lazy loading with code splitting

Another general technique to keep in your bag of tricks is lazy loading of code bundles. The general idea here is that you only load data once it becomes necessary. React 16.6 introduced React.lazy(), which allows for the more idiomatic use of code splitting (meaning you can use normal component syntax and still get lazy loading semantics).

In React versions prior to React 16.6, the process of code splitting is a bit more cumbersome, but still can offer worthwhile improvements for large code bases.

Concurrent mode, Suspense, and useDeferredValue

One of the newest features and biggest changes in React 16 is concurrent mode. The full details of how to use concurrent mode is beyond the scope of this article, but know that using the Suspense component can vastly improve the actual and perceived performance of your application. Concurrent mode means that fetching and rendering can occur in parallel.

In addition to the Suspense component, which allows for defining data fetching zones, React 16 exposes other ingenious tricks like useDeferredValue, which can improve the way things like auto-suggest work, avoiding poor user experiences like type stutter.

Debounce or throttle data fetching

Most cases in which you would use the debounce or throttle functions are better handled by React’s concurrent mode, described above. If concurrent mode is unavailable to you (because your codebase is locked into using the legacy rendering engine), then you can use these functions to avoid cases where a naive strategy will cause excessive chatter in data fetching.

As an example, in the case of fetching data while the user is typing, if you simply fire off a request for every keystroke, you will encounter poor performance. Using debounce or throttle can help alleviate this problem. But again, concurrent mode opens up improved ways to address these issues.

Profiling

We’ve explored many specific techniques for improving React app performance. Now it’s important to mention that profiling your application is critical, both for gaining an understanding of where your bottlenecks are and for verifying that the changes you implement are effective.

You can use the built-in profiler that comes with browsers like Chrome and Firefox. React’s dev mode (when enabled) will allow you to see the specific components in use when looking at the profiler. This is also useful for examing the network tab, and identifying any back-end calls that are slow. These are areas not directly fixable by you in the JavaScript, but perhaps could be fixed on the back end.

Newer version of React (16.5 and later) offer a DevTools Profiler that offers more detailed capabilities and integrates with the new concurrent mode features. The DevTools Profiler offers many ways to slice and dice your application’s activity.

There is also a Profiler component that exposes detailed information about the component rendering lifecycle.

React production build

As a final note, when deploying to production, the production build should be used. The steps for this are dependent on the build tool you are using. For example, the steps for the Create React App are here. The production build is minified and doesn’t contain dev logging. The same goes for your custom code: Debug logging should be disabled when deploying to production.

Performance is a critical aspect of the web UI, as it directly impacts the user experience and how users will feel about the application. This article has given you a number of specific techniques and general approaches to improving React UI performance.

Copyright © 2021 IDG Communications, Inc.