2022-09-12T11:38:43Z

The React Mega-Tutorial, Chapter 10: Memoization

An important part of React that you haven't seen yet is memoization, a technique that helps prevent unnecessary renders and improve the overall performance of the application. In this chapter you are going to learn how the React rendering algorithm works, and how you can make it work efficiently for your application.

The complete course, including videos for every chapter is available to order from my courses site. Ebook and paperback versions of this course are also available from Amazon. Thank you for your support!

For your reference, here is the complete list of articles in this series:

The React Rendering Algorithm

Before looking at how to optimize rendering in React applications, let's review what happens while the application renders.

During the initial load, React renders the top component of the application, often called App. It does this by invoking its render function. Because this is the initial render, React also renders all the children components referenced in the JSX returned by App.

However, the component render functions represent half of the picture. In React, renders are carried out in two phases. First, output of the render functions is applied to the virtual DOM, which is an internal copy of the actual DOM from the browser. Once the components are rendered on the virtual DOM, the second phase invokes an efficient algorithm to merge the virtual DOM to the real DOM, and this is what makes the components visible on the page. On the initial render, the entire contents of the virtual DOM have to be copied over to the real DOM, but once the application is running and the real DOM is populated, the merge process only copies the elements that are different, making updates more efficient.

Once the two phases of the first render complete and the application is visible on the page, React runs the side effect functions that were started by many of the components. These functions do some work, and will eventually update some of the state variables that are held by components.

Every time a state variable changes, React determines which of the components that are on the page have a dependency on this variable. These affected components are re-rendered, to give them a chance to refresh their state. These renders will also be made in two phases, first the render results are applied to the virtual DOM, and then the virtual DOM is efficiently merged to the real DOM.

This cycle can sometimes continue, because the renders that were started after state variables changed may launch new side effect functions, which will in turn change more state variables that will trigger even more re-renders. But the application will eventually settle, with all the components and state variables updated, and all the side effect functions finished. When the application reaches this state, React has no more work to do.

But at this point the user may click a button, or a link. Event handlers attached to these UI elements will be invoked by the browser, and these will very likely end up changing some state variables, starting another chain of renders.

As you can see, the rendering algorithm is very optimized to only update the parts of the component tree that depend on state changes. While the optimizations React implements are very useful, they don't always prevent performance problems.

Unnecessary Renders

Sometimes a component renders even though none of its inputs or state variables have changed. When that happens, the render function still runs, and its results are applied to the virtual DOM. Presumably the result of the render is going to be identical to the previous render, since no inputs have changed. When the render reaches the second phase, React will find nothing to merge from virtual to real DOM.

It is good that React is able to identify these unnecessary renders and avoid updating the real DOM when they happen. But it is also bad that React sometimes does not realize that a component that is in the queue for rendering hasn't changed, because if it did, it could avoid rendering it altogether.

Unfortunately unnecessary renders are more common that most developers think. When a component renders, it returns a new JSX subtree. Any child components that are included in the returned JSX are forced to re-render even if they don't change, simply because they generated anew during the parent's render.

As a general rule, you can say that unless measures are taken to prevent it, each time a component renders, any child components included in the JSX returned have to render too.

In many cases the cost of these unnecessary renders is negligible, but this isn't always the case. Consider the Posts component, which has 25 instances of the Post component. If the user clicks the "More" button to obtain the 25 posts, all 50 posts will re-render.

Memoizing Components

Given the same inputs, the vast majority of components will return the same output. So you could make the argument that it is unnecessary to re-render a component when neither its props nor its state variables have changed.

This is the idea behind the memo() function provided by React. The function wraps any component to give it additional logic that can decide to skip the render when the inputs did not change.

This can be used to optimize the rendering of the Posts component when pagination is used to add more children to it. To prevent these unnecessary renders, the Post component can be memoized by adding the memo() function to it as a wrapper.

src/components/Post.js: Memoize the component

import { memo } from 'react';
... // <-- no changes to existing imports

export default memo(function Post({ post }) {
  ... // <-- no changes to function body
});

This change transparently replaces the original Post component with a version of it that is optimized to skip the render when it is invoked with the same inputs as the previous time.

Should all components be memoized? This is a difficult question to answer. It is easy to visualize the performance improvements for components such as Post, which are often re-rendered by React, just because the parent renders.

It is hard to say that it is beneficial to memoize components that do not render as often, or that generally render with different inputs, because the logic added by memo() also has a cost. If you are interested in finding out if applying memo() to a component makes your application render faster, a specialized tool such as the profiler included in the React Developer Tools plugin for Chrome or Firefox should be used to take accurate measurements.

Render Loops

Another common problem related to the React rendering algorithm happens when endless render cycles are inadvertently introduced across many components. These occur when application re-renders parts of its tree indefinitely due to cyclical dependencies. An endless render cycle will cause high CPU usage and poor application performance, so it should be avoided. The application in its current state does not have any render loops, but it is actually easy to inadvertently introduce one.

A feature that would be nice to have in Microblog is a global error handler that can show an error alert to the user when the server is unresponsive or offline. To implement this feature, the MicroblogApiClient class constructor can be expanded to accept an onError argument that the caller can use to pass a custom error handler function.

src/MicroblogApiClient.js: custom error handler

export default class MicroblogApiClient {
  constructor(onError) {
    this.onError = onError;
    this.base_url =  BASE_API_URL + '/api';
  }

  async request(options) {
    let response = await this.requestInternal(options);
    if (response.status === 401 && options.url !== '/tokens') {
      ... // <-- no changes to retry logic
    }
    if (response.status >= 500 && this.onError) {
      this.onError(response);
    }
    return response;
  }

  ... // <-- no changes to the rest of the class
}

If the caller passes an error handler, then it is called when the response to a request has a 500 or higher status code, which according to the HTTP specification are associated with server failures.

With the error handling support incorporated into the API client, the ApiProvider component can create a handler that flashes an error message, so that the user knows there is a problem. The updates to the ApiProvider component are shown below.

src/contexts/ApiProvider.js: Error handling

import { createContext, useContext } from 'react';
import MicroblogApiClient from '../MicroblogApiClient';
import { useFlash } from './FlashProvider';

export const ApiContext = createContext();

export default function ApiProvider({ children }) {
  const flash = useFlash();

  const onError = () => {
    flash('An unexpected error has occurred. Please try again.', 'danger');
  };

  const api = new MicroblogApiClient(onError);

  // <-- no changes to the returned JSX
}

... // <-- no changes to the hook function

While this implementation appears to be correct at first sight, the reality is that it has introduced a render loop. It is really difficult to spot it, but see if you can identify where the loop is.

The following list is a step by step account of what would happen when the server returns a 500 or higher response after a user action that required an API call:

  1. The MicroblogApiClient instance owned by the ApiProvider component receives an error response from the back end, and invokes the onError() function, also owned by ApiProvider.
  2. The onError() function in ApiProvider invokes the flash() function to display an error alert.
  3. The flash() function, owned by the FlashProvider component, calls the setFlashMessage() function to update the flashMessage state.
  4. Because the flashMessage state variable was changed, React decides that the FlashProvider component, which owns it, must be re-rendered.
  5. The FlashProvider render function creates new flash() and hideFlash() functions (because these are defined locally inside the render function) and updates the value of the FlashContext with them.
  6. After these last changes, the ApiProvider component depends on the flash() function. Because of this dependency, React re-renders ApiProvider.
  7. The ApiProvider render function creates a new instance of the MicroblogApiClient class. A new onError() handler function is created as well, and configured into it.
  8. The UserProvider component uses the useApi() hook to get the MicroblogApiClient instance. Because this instance changed, React decides UserProvider needs to re-render.
  9. The UserProvider has a side effect function that depends on the api instance. Since this instance changed, React re-runs the side effect.
  10. The side effect function in UserProvider attempts to load the currently logged-in user by sending a request to the /me endpoint in the server. A malfunction in the API is what started this render cycle, so the most likely outcome for another call to the API is to also end in error, and this would make this entire render process repeat from the beginning. The cycle will continue until the API stops returning errors.

How can you prevent loops such as this one? You can study the source code for all the components involved, and you may still not see a straightforward way to break the loop. These components just have a circular chain of relationships, so the first solution you may consider is to eliminate some dependencies, at the cost of removing features. For example, if the onError() handler didn't use the flash() function, then this issue wouldn't case a render loop.

Luckily there is another way to look at the problem, without having to compromise on the features. If you review the sequence of actions carefully, you may see that step 5 is when things started to get out of control.

As a result of the first API error, the onError() handler calls the flash() function, so the FlashProvider needs to re-render to publish the alert message to the FlashContext.

However, an unintended consequence of this re-render is that the flash() and hideFlash() functions stored in the context are replaced with brand-new versions. These new functions are identical to the original ones, but they are new copies, because the functions are declared in the local scope of the component's render function. The new flash() function is what makes React decide that ApiProvider needs to re-render in step 6. In other words, if there was a way to prevent the flash() function from changing, then ApiProvider and any other components that use it would not become outdated and would not need to re-render.

Similarly, when ApiProvider re-renders, it creates a new api object, which is added to the ApiContext in step 7. This change is what makes React decide that UserProvider needs to re-render in step 8, to prevent it from having a stale api value. But the api object is an instance of the MicroblogApiClient class which is not affected by any of the other changes. If the api object could be kept the same when ApiProvider re-renders, then other components that depend on it would not need to re-render just so that they get the new object, which is 100% equivalent to the old one.

The solution that often allows these circular dependencies to not loop forever is to ensure that functions and objects created in the local context of a render function are not updated unnecessarily. In other words: if a function or object created in a component's render function is not directly affected by the change that triggered the render, then it should not change. This can also be handled by memoization, but at a more granular scale than the memo() function discussed above.

Memoizing Functions and Objects

As discussed above, each time a component renders, new instances of any functions and objects allocated inside its render function are created. Sometimes it is necessary to update these items, but many times using new instances is unnecessary. React provides the useCallback() and useMemo() hooks to memoize functions and other values.

To memoize a function, it needs to be wrapped with the useCallback() hook function. This function takes the function to memoize as the first argument, and a dependency list similar to the one used in the useEffect() callback as second argument. As long as the dependencies declared in the second argument don't change, the function will not change.

You can see how to memoize flash() and hideFlash() below.

src/contexts/FlashProvider.js: Memoize flash() and hideFlash()

import { createContext, useContext, useState, useCallback } from 'react';

export const FlashContext = createContext();
let flashTimer;

export default function FlashProvider({ children }) {
  const [flashMessage, setFlashMessage] = useState({});
  const [visible, setVisible] = useState(false);

  const hideFlash = useCallback(() => {
    ... // <-- no changes in the function body
  }, []);

  const flash = useCallback((message, type, duration = 10) => {
    ... // <-- no changes in the function body
  }, [hideFlash]);

  // <-- no changes to the returned JSX
}

... // <-- no changes to the hook function

The hideFlash() was moved above flash(), because flash() calls it, which means that it needs to be listed as a dependency. Remember that the React development build detects these missing dependencies and warns you about any missing ones, so always check the warning in the terminal where you run the development web server.

The next listing shows the memoizing changes for ApiProvider.

src/contexts/ApiProvider.js: Memoize onError and api

import { createContext, useContext, useCallback, useMemo } from 'react';
import MicroblogApiClient from '../MicroblogApiClient';
import { useFlash } from './FlashProvider';

export const ApiContext = createContext();

export default function ApiProvider({ children }) {
  const flash = useFlash();

  const onError = useCallback(() => {
    flash('An unexpected error has occurred. Please try again later.', 'danger');
  }, [flash]);

  const api = useMemo(() => new MicroblogApiClient(onError), [onError]);

  // <-- no changes to the returned JSX
}

... // <-- no changes to the hook function

The onError() function is memoized just like before, with the dependency list passed in the second argument to useCallback() set to the flash() function, which is used inside the body of the function.

The api object cannot be memoized with useCallback(), which only works with functions. The useMemo() hook is a more generic version of useCallback() that can be used to memoize values of any type. The first argument to this hook is a function that returns the value to memoize. The second argument is the dependency list, same as in useCallback(). For api, the only dependency is the onError() function, defined right above.

The UserProvider component was also part of the render loop, but now that api is memoized, this component will only re-render when its user state variable changes, which only happens when the user logs in or out. Even though the render loop is addressed with the above changes, it makes sense to also memoize UserProvider, since it has two functions shared in its context that should not change unless strictly necessary to avoid children components that use them to re-render unnecessarily.

src/contexts/UserProvider.js: Memoize login() and logout()

import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useApi } from './ApiProvider';

export const UserContext = createContext();

export default function UserProvider({ children }) {
  const [user, setUser] = useState();
  const api = useApi();

  ... // <-- no changes to side effect function

  const login = useCallback(async (username, password) => {
    ... // <-- no changes in the function body
  }, [api]);

  const logout = useCallback(async () => {
    ... // <-- no changes in the function body
  }, [api]);

  // <-- no changes to the returned JSX
}

... // <-- no changes to the hook function

If you are running the Microblog API service locally and would like to try the global error handler, you can stop the service by pressing Ctrl-C in the terminal session it is running, and then the next operation you trigger in the application that requires access to the server is going to show the error alert. Once you restart the API you will be able to continue using the application.

Chapter Summary

  • Optimize components that render many times with the same inputs by memoizing them with the memo() function from React.
  • Memoizing functions and objects defined in high-level components such as those sharing contexts with useCallback() and useMemo(), so that children components that depend on them do not render unnecessarily.

Leave a Comment