Skip to content
On this page

Performance in React (Advanced)

This section is just here for the sake of learning about performance improvements in React. We wouldn't need optimizations in most React applications, as React is fast out of the box. While more sophisticated tools exist for performance measurements in JavaScript and React, we will stick to a simple console.log() and our browser's developer tools for the logging output.

Strict Mode

Before we can learn about performance in React, we will briefly look at React's Strict Mode which gets enabled in the src/main.jsx file:

src/main.jsx

jsx
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

React's Strict Mode is a helper component which notifies developers in the case of something being wrong in our implementation. For example, using a deprecated React API (e.g. using a legacy React hook) would give us a warning in the browser's developer tools. However, it also ensures that state and side-effects are implemented well by a developer. Let's experience what this means in our code.

The App component fetches initially data from a remote API which gets displayed as a list. We are using React's useEffect hook for initializing the data fetching. Now I encourage you to add a console.log() which logs whenever this hook runs:

src/main.jsx

jsx
const App = () => {
  ...

  React.useEffect(() => {
    console.log('How many times do I log?');
    handleFetchStories();
  }, [handleFetchStories]);

  ...
};

Many would expect seeing the logging only once in the browser's developer tools, because this side-effect should only run once (or if the handleFetchStories function changes). However, you will see the logging twice for the App component's initial render. To be honest, this is a highly unexpected behavior (even for seasoned React developers), which makes it difficult to understand for React beginners. However, the React core team decided that this behavior is needed for surfacing bugs related to misused side-effects in the application.

So React's Strict Mode runs React's useEffect Hooks twice for the initial render. Because this results in fetching the same data twice, this is not a problem for us. The operation is called idempotent, which means that the result of a successfully performed request is independent of the number of times it is executed. After all, it's only a performance problem, because there are two network requests, but it doesn't result in a buggy behavior of the application. In addition to all of this uncertainty, the Strict Mode is only applied for the development environment, so whenever this application gets build for production, the Strict Mode gets removed automatically.

Both of these behaviors, running React's useEffect Hook twice for the initial render and having different outcomes between development and production, surface many warranted discussions around React's Strict Mode.

For the following performance sections, I encourage you to disable the Strict Mode by simply removing it. This way, we can follow the logging that would happen for this application once it is build for a production environment:

src/main.jsx

jsx
ReactDOM.createRoot(document.getElementById('root')).render(
  <App />
);

However, at the end of the performance sections, I encourage you to add the Strict Mode back again, because it is there to help you after all.

Don't run on first render

Previously, we have covered React's useEffect Hook, which is used for side-effects. It runs the first time a component renders (mounting), and then every re-render (updating). By passing an empty dependency array to it as a second argument, we can tell the hook to run on the first render only. Out of the box, there is no way to tell the hook to run only on every re-render (update) and not on the first render (mount). For example, examine our custom hook for state management with React's useState Hook and its semi-persistent state with local storage using React's useEffect Hook:

src/App.jsx

jsx
const useStorageState = (key, initialState) => {
  const [value, setValue] = React.useState(
    localStorage.getItem(key) || initialState
  );

  React.useEffect(() => {
    console.log('A');
    localStorage.setItem(key, value);
  }, [value, key]);

  return [value, setValue];
};

With a closer look at the developer's tools, we can see the log for the first time when the component renders using this custom hook. It doesn't make sense to run the side-effect for the initial rendering of the component though, because there is nothing to store in the local storage except the initial value. It's a redundant function invocation, and should only run for every update (re-rendering) of the component.

As mentioned, there is no React Hook that runs on every re-render, and there is no way to tell the useEffect hook in a React idiomatic way to call its function only on every re-render. However, by using React's useRef Hook which keeps its ref.current property intact over re-renders, we can keep a made up state (without re-rendering the component on state updates) with an instance variable of our component's lifecycle:

src/App.jsx

jsx
const useStorageState = (key, initialState) => {
  const isMounted = React.useRef(false);

  const [value, setValue] = React.useState(
    localStorage.getItem(key) || initialState
  );

  React.useEffect(() => {
    if (!isMounted.current) {
      isMounted.current = true;
    } else {
      console.log('A');
      localStorage.setItem(key, value);
    }
  }, [value, key]);

  return [value, setValue];
};

We are exploiting the ref and its mutable current property for imperative state management that doesn't trigger a re-render. Once the hook is called from its component for the first time (component render), the ref's current property is initialized with a false boolean called isMounted. As a result, the side-effect function in useEffect isn't called; only the boolean flag for isMounted is toggled to true in the side-effect. Whenever the hook runs again (component re-render), the boolean flag is evaluated in the side-effect. Since it's true, the side-effect function runs. Over the lifetime of the component, the isMounted boolean will remain true. It was there to avoid calling the side-effect function for the first time render that uses our custom hook.

The above was only about preventing the invocation of one simple function for a component rendering for the first time. But imagine you have an expensive computation in your side-effect, or the custom hook is used frequently in the application. It's more practical to deploy this technique to avoid unnecessary function invocations.

Exercises:

Don't re-render if not needed

Earlier, we have explored React's re-rendering mechanism. We'll repeat this exercise for the App and List components. For both components, add a logging statement:

src/App.jsx

jsx
const App = () => {
  ...

  console.log('B:App');

  return ( ... );
};

const List = ({ list, onRemoveItem }) =>
  console.log('B:List') || (
    <ul>
      {list.map((item) => (
        <Item
          key={item.objectID}
          item={item}
          onRemoveItem={onRemoveItem}
        />
      ))}
    </ul>
  );

Because the List component has no function body, and developers are lazy folks who don't want to refactor the component for a simple logging statement, the List component uses the || operator instead. This is a neat trick for adding a logging statement to a function component without a function body. Since the console.log() on the left-hand side of the operator always evaluates to false, the right-hand side of the operator gets always executed.

Code Playground

js
function getTheTruth() {
  if (console.log('B:List')) {
    return true;
  } else {
    return false;
  }
}

console.log(getTheTruth());
// B:List
// false

Let's focus on the actual logging in the browser's developer tools when refreshing the page. You should see a similar output. First, the App component renders, followed by its child components (e.g. List component).

Visualization

bash
B:App
B:List
B:App
B:App
B:List

Again: If you are seeing more than these loggings, check whether your src/main.jsx file uses <React.StrictMode> as a wrapper for your App component. If it's the case, remove the Strict Mode and check your logging again. Explanation: In development mode, React's Strict Mode renders a component twice to detect problems with your implementation in order to warn you about these. This Strict Mode is automatically excluded for applications in production. However, if you don't want to be confused by the multiple renders, remove Strict Mode from the src/main.jsx file.

Since a side-effect triggers data fetching after the first render, only the App component renders, because the List component is replaced by a loading indicator in a conditional rendering. Once the data arrives, both components render again.

Visualization

bash
// initial render
B:App
B:List

// data fetching with loading instead of List component
B:App

// re-rendering with data
B:App
B:List

So far, this behavior is acceptable, since everything renders on time. Now we'll take this experiment a step further, by typing into the SearchForm component's input field. You should see the changes with every character entered into the element:

Visualization

bash
B:App
B:List

What's striking is that the List component shouldn't re-render, but it does. The search feature isn't executed via its button, so the list passed to the List component via the App component remains the same for every keystroke. This is React's default behavior, re-rendering everything below a component (here: the App component) with a state change, which surprises many people. In other words, if a parent component re-renders, its descendent components re-render as well. React does this by default, because preventing a re-render of child components could lead to bugs. Because the re-rendering mechanism of React is often fast enough by default, the automatic re-rendering of descendent components is encouraged by React.

Sometimes we want to prevent re-rendering, however. For example, huge data sets displayed in a table (e.g. List component) shouldn't re-render if they are not affected by an update (e.g. Search component). It's more efficient to perform an equality check if something changed for the component. Therefore, we can use React's memo API to make this equality check for the props:

src/App.jsx

jsx
const List = React.memo(
  ({ list, onRemoveItem }) =>
    console.log('B:List') || (
      <ul>
        {list.map((item) => (
          <Item
            key={item.objectID}
            item={item}
            onRemoveItem={onRemoveItem}
          />
        ))}
      </ul>
    )
);

React's memo API checks whether the props of a component have changed. If not, it does not re-render even though its parent component re-rendered. However, the output stays the same when typing into the SearchForm's input field:

Visualization

bash
B:App
B:List

The list passed to the List component is the same, but the onRemoveItem callback handler isn't. If the App component re-renders, it always creates a new version of this callback handler as a new function. Earlier, we used React's useCallback Hook to prevent this behavior, by creating a function only on the initial render (or if one of its dependencies has changed):

src/App.jsx

jsx
const App = () => {
  ...

  const handleRemoveStory = React.useCallback((item) => {
    dispatchStories({
      type: 'REMOVE_STORY',
      payload: item,
    });
  }, []);

  ...

  console.log('B:App');

  return (... );
};

Since the callback handler gets the item passed as an argument in its function signature, it doesn't have any dependencies and is declared only once when the App component initially renders. None of the props passed to the List component should change now. Try it with the combination of React memo and useCallback, to search via the SearchForm's input field. The "B:List" logging disappears, and only the App component re-renders with its "B:App" logging.

While all props passed to a component stay the same, the component renders again if its parent component is forced to re-render. That's React's default behavior, which works most of the time because the re-rendering mechanism is pretty fast. However, if re-rendering decreases the performance of a React application, React's memo API helps prevent re-rendering. As we have seen, sometimes memo alone doesn't help, though. Callback handlers are re-defined each time in the parent component and passed as changed props to the component, which causes another re-render. In that case, useCallback is used for making the callback handler only change when its dependencies change.

Exercises:

Don't rerun expensive computations

Sometimes we'll have performance-intensive computations in our React components -- between a component's function signature and return block -- which run on every render. For this scenario, we must create a use case in our current application first:

src/App.jsx

jsx
const getSumComments = (stories) => {
  console.log('C');

  return stories.data.reduce(
    (result, value) => result + value.num_comments,
    0
  );
};

const App = () => {
  ...

  const sumComments = getSumComments(stories);

  return (
    <div>
      <h1>My Hacker Stories with {sumComments} comments.</h1>

      ...
    </div>
  );
};

If all arguments are passed to a function, it's acceptable to have it outside the component, because it does not have any further dependency needed from within the component. This prevents creating the function on every render, so the useCallback hook becomes unnecessary. However, the function still computes the value of summed comments on every render, which becomes a problem for more expensive computations.

Each time text is typed in the input field of the SearchForm component, this computation runs again with an output of "C". This may be fine for a non-heavy computation like this one, but imagine this computation would take more than 500ms. It would give the re-rendering a delay, because everything in the component has to wait for this computation. We can tell React to only run a function if one of its dependencies has changed. If no dependency changed, the result of the function stays the same. React's useMemo Hook helps us here:

src/App.jsx

jsx
const App = () => {
  ...

  const sumComments = React.useMemo(
    () => getSumComments(stories),
    [stories]
  );

  return ( ... );
};

For every time someone types in the SearchForm, the computation shouldn't run again. It only runs if the dependency array, here stories, has changed. After all, this should only be used for cost expensive computations which could lead to a delay of a (re-)rendering of a component.

Now, after we went through these scenarios for useMemo, useCallback, and memo, remember that these shouldn't necessarily be used by default. Apply these performance optimizations only if you run into performance bottlenecks. Most of the time this shouldn't happen, because React's rendering mechanism is pretty efficient by default. Sometimes the check for utilities like memo can be more expensive than the re-rendering itself.

Exercises:

  • Compare your source code against the author's source code.
  • Read more about React's useMemo Hook.
  • Download React Developer Tools as an extension for your browser. Open it for your application in the browser via the browser's developer tools and try its various features. For example, you can use it to visualize React's component tree and its updating components.
  • Does the SearchForm re-render when removing an item from the List with the "Dismiss"-button? If it's the case, apply performance optimization techniques (using useCallback and memo) to prevent re-rendering.
  • Does each Item re-render when removing an item from the List with the "Dismiss"-button? If it's the case, apply performance optimization techniques to prevent re-rendering.
  • Remove all performance optimizations to keep the application simple. Our current application doesn't suffer from any performance bottlenecks. Try to avoid premature optimzations. Use this section and its further reading material as a reference, in case you run into performance problems.
  • Optional: Leave feedback for this section.