24-Dec

React

Three techniques for faster React apps

“Premature optimization is the root of all evil” is an oft repeated adage, and rightly so. But what do we do when optimization is long overdue in our React apps? Let’s explore some tools and techniques to deal with exactly that.

7 min read

·

By Ferdinand Gumø Løberg

·

December 24, 2023

Imagine you are building a React app. The MVP is done, ready to launch, and you decide to take it for a final test ride on your dingy home computer instead of the state-of-the-art workstation you usually use. As you open the website, everything grinds to a halt.

Your CPU load somehow hits 238%. No application is responding. You try to click a few buttons, but you're having difficulty hitting some of the smaller ones because the fans now produce enough thrust to lift your laptop off the table. You type a word into your search bar with AI-powered autosuggestions, and arcs of electricity shoot off the power lines outside your home, damaging nearby cars.

Does this sound familiar? Then you might have a performance problem! Let's explore some tools and techniques to deal with them.

Detecting performance issues

To fix performance issues, we first need to detect them. I recommend getting familiar with two tools: the "performance"-tab in your browser's developer tools, and React developer tools.

Your browser's developer tools are great for mapping out what is causing the slowdown. It will show you a breakdown of how much time each function used in a given period, giving you an idea of which parts of your application need some attention. Here is a picture of a flame graph from an example application:

Flame graph of the performance of a React application recorded using Firefox developer tools

We can see the time it takes to render two components, SortedDateComponent and OtherComponent. The SortedDateComponent takes longer by a wide margin, which is a good indicator that it may be a performance bottleneck. We can also see two likely culprits within the SortedDateComponent, namely generateDate to the left and sortDates to the right. This tells us they may be good targets for optimization.

The React developer tools are a bit more specialized, as they are mostly used to look at things specific to React. Below is a recording of the same application, using the React profiler:

Graph of React performance recorded using React developer tools

Here we see a similar breakdown, but more stripped down. While we don't get as much detail as the previous picture, what is more interesting here is seeing what caused the render to happen and the relationship between the components. In this case, some state in the DateComponent changed, causing its children to rerender.

It also separates each render into its own chart. This can be useful for example when a component renders twice when it should render once, which is difficult to catch with the browser devtools.

One thing I would like to mention is that the development build of React is essentially needed to use these tools, as it forgoes a lot of the minification steps that the production build performs. It also takes some computing power to run these tools, so your performance will be significantly worse than in your production environment.

Fixing 'em

Now that we have detected where are issues are, we need ways to resolve them. The two important aspects of rendering performance are how many times a component renders, and how long that render takes. If we decrease either, or ideally both, we will see improved performance.

First, let's talk about why React rerenders. The main purpose of React is to keep your application state and UI in sync. That means that whenever your state changes, React needs to rerun the rendering functions that use this state to see if it needs to update the UI. In effect, this means that generally a component will rerender whenever it or its parent's state changes.

const Counter1 = () => {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

const Counter2 = () => {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(0)}>{count}</button>;
};

const Counter3 = () => {
  const [state, setState] = useState({ count: 0 });
  return <button onClick={() => setState({ count: 0 })}>{state.count}</button>;
};

Here are three components to illustrate this point.

The first component will rerender whenever we press the button, because the count changes and React needs to update the UI to reflect this change.

The second component will not. Even though we call the setCount-function, it does not need to rerender because the value remains the same.

The third component will, somewhat counterintuitively, rerender. This is because we're using a state object instead of storing the primitive value directly. When React checks if state has changed, objects are compared by identity instead of by value. Since we construct a new object when calling the setState-function, even though it has the same count-value, React will see them as different objects and rerender.

There is one important caveat to the third component. Since the UI stays the same, unless we add some stupidly expensive function to the component, it will be a cheap render performance-wise.

Tip #1: Dealing with stupidly expensive functions in components

The best way to deal with expensive functions is to make the functions faster. Sometimes though we can't be bothered, or we don't have time, or we don't know how, or the function simply can't be made faster. For these cases, we can use the useMemo-hook. We will use the third function from the previous paragraph with an expensive function added as an example.

const Counter3 = () => {
  const [state, setState] = useState({ count: 0 });
  const displayValue = expensiveFunction(state.count);
  return <button onClick={() => setState({ count: 0 })}>{displayValue}</button>;
};

The way useMemo works is that it makes the calculation of a value conditional on its dependencies. It will store the calculated value, and if its dependencies don't change between renders it will use the old value instead of recalculating it. In this case, since expensiveFunction is dependent on state.count, wrapping it in useMemo means it will only be recalculated if state.count changes.

const Counter3 = () => {
  const [state, setState] = useState({ count: 0 });
  const displayValue = useMemo(
    () => expensiveFunction(state.count),
    [state.count]
  );
  return <button onClick={() => setState({ count: 0 })}>{displayValue}</button>;
};

Although the whole component will rerender when we press the button, because the dependency is a primitive value, useMemo will not rerun expensiveFunction.

There are some downsides to useMemo that you need to consider. The first, and most obvious, is that it will not improve the performance of the initial render. So if a component is rendered once and never again, useMemo does nothing.

Secondly, if the dependencies of your function changes between most rerenders anyway, useMemo will actually be less performant because comparing dependencies to see if they change takes work. It will also make the code a bit harder to read, and more bug prone because you need to keep track of which dependencies your function has.

Tip #2: Separating children from their parents

A common misconception is that components rerender when their props change. In actuality, components usually rerender when their parents rerender.

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <ExpensiveComponent />
    </>
  );
};

In this example, even though ExpensiveComponent recieves no props, it will rerender when we click the button. A quick fix here is to move the counting logic to a separate component, like so:

const App = () => {
  return (
    <>
      <Counter />
      <ExpensiveComponent />
    </>
  );
};

const Counter = () => {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

This way the state change is on a lower level, meaning that the rerender does not propagate to ExpensiveComponent. It may seem obvious when presented like this, but it can be difficult to spot solutions like this in a larger project with deeply nested components, and maybe even some global state like Redux involved.

Tip #3: Memoizing whole components

Sometimes our dependencies are not as straightforward.

const App = () => {
  const [firstCount, setFirstCount] = useState(0);
  const [secondCount, setsecondCount] = useState(0);

  const combinedCount = firstCount + secondCount
  return (
    <>
      <button onClick={() => setFirstCount(firstCount + 1)}>
        {firstCount}
      </button>
      <button onClick={() => setsecondCount(secondCount + 1)}>
        {secondCount}
      </button>

      {combinedCount}

      <ExpensiveComponent value={firstCount} />
      <ExpensiveComponent value={secondCount} />
    </>
  );
};

In this case, we cannot move the counters to separate components because of the combinedCount variable. But we also unnecessarily rerender one ExpensiveComponent when the other changes. To deal with this, we can use memo.

const MemoizedExpensiveComponent = React.memo(ExpensiveComponent)

const App = () => {
  const [firstCount, setFirstCount] = useState(0);
  const [secondCount, setsecondCount] = useState(0);

  const combinedCount = firstCount + secondCount
  return (
    <>
      <button onClick={() => setFirstCount(firstCount + 1)}>
        {firstCount}
      </button>
      <button onClick={() => setsecondCount(secondCount + 1)}>
        {secondCount}
      </button>

      {combinedCount}

      <MemoizedExpensiveComponent value={firstCount} />
      <MemoizedExpensiveComponent value={secondCount} />
    </>
  );
};

While useMemo works within a component, memo wraps around components. Earlier I wrote that components usually rerender whenever their parents rerender, and this is the exception. When a component is memoized, it only rerenders when one of its props change.

This works well in combination with useMemo, as you can memoize a value that you then pass down as a prop.

const MemoizedExpensiveComponent = React.memo(ExpensiveComponent);

const App = () => {
  const users = getUsers();

  const sortedUsers = useMemo(sort(users), [users]);

  return <MemoizedExpensiveComponent users={sortedUsers} />;
};

Without the useMemo-hook the list would be recreated on every rerender by the sorting function, which would cause the expensive component to rerender as well. It is important to note that this only works if we assume the value from getUsers has a stable identity.

Using memo has a lot of the same downsides as useMemo: the initial render will not be affected, if props change on every rerender it will have no effect, and it leads to more complicated code.

Closing thoughts

I think it's important to keep in mind that you most likely don't need to worry about performance optimization unless you have performance issues. Try to write code that can be easily optimized, know your users and their performance needs, but do not let perfection get in the way of progress.

Thanks for reading all the way through, I hope you found some of it useful! These tips really only scratch the surface, but maybe they can be a springboard for some of you into the vast ocean that is performance optimization. Happy holidays!