Building great user experiences with React Suspense - a deep dive
Edument

Building great user experiences with React Suspense - a deep dive

This post introduces React Suspense, a part of React’s upcoming concurrent mode that lets developers declaratively await components and their dependent resources, such as data, code, images and other assets, to load asynchronously before rendering. Suspense allows for orchestrating complex loading sequences and visual transitions with ease, greatly improving both the developer and user experience.

Though Suspense and concurrent mode are still experimental features, a number of 3rd party software already support it; in the example that follows the react-query library will be utilized for data fetching via its Suspense integration.


The full example is available at Github, in the src/ex05 folder.


Suspense Overview

Suspense was introduced in React 16.6 for the specific use case of code splitting and lazy-loading components:

// listing 1.
import React, {
  lazy,
  Suspense,
} from 'react';

// lazy-load the Home component upon rendering.
const Home = lazy(() => import('./Home'));

function App() {
  return (
    <Suspense fallback={'Loading...'}>
      <Home />
    </Suspense>
  )
}


The following occurs in the listing above:

  1. The App component attempts to render the Home component, which is wrapped in a so-called Suspense boundary.

  2. As the Home component is lazy-loaded, React starts fetching its corresponding code chunk over the network. The Promise object returned from the dynamic import() call will be thrown and propagate upwards to the nearest Suspense boundary.

  3. The declared Suspense boundary catches the promise and, while it is pending (i.e. awaiting the network response), displays the fallback component.

  4. Once the promise resolves, the Home component renders.


Thus, rendering of the Home component suspends while its associated resource - the component code - is being fetched, and later resumes.


Via promises, 3rd party code such as the react-query data fetching library have a standard mechanism to signal to React whether a resource that a component requires is ready or not.


Data Fetching

With Concurrent Mode, Suspense has been extended to allow an application to wait for any type of resource to be fetched, including data, assets such as images and documents, and more. This section will examine such scenarios and explore the Suspense API via a complete example.


Orchestrating Loading Sequences

The example application allows for selecting a Github user and viewing associated profile data, such as name and avatar, repositories and followers.

Note: Each listing below refers to the corresponding source file on Github that contains the code shown.

Some material in the source files has been omitted in the code listings below for brevity.


When a Github user is chosen (clicked), the markup for the profile view is rendered:

// listing 2: src/ex05/App.js

<Suspense fallback={`Loading ${currentUser}...`}>
  <User user={currentUser} />

  <Suspense fallback={`Loading repos...`}>
    <Repos endpoint={`${BASE_URL}/${currentUser}/repos`} />           
  </Suspense>

  <Suspense fallback={`Loading followers...`}>
    <Followers endpoint={`${BASE_URL}/${currentUser}/followers`} />
  </Suspense>
</Suspense>


Note that several Suspense boundaries have been declared; an outer boundary and two inner boundaries. Their nested usage will be explained in the section Nested Suspense Boundaries below.


There are three components - User, Repos and Followers - that are able to suspend as they wait for their data dependencies to load. Let’s investigate the first component that React attempts to render - User:

// listing 3: src/ex05/User.js

function User({ user }) {
  // upon first render, this throws a promise and stops further execution, as the component suspends.
  const data = useFetch(`${BASE_URL}/${user}`);

  // this code is reached upon second render.
  const {
    avatar_url,
    name
  } = data;

  return (
    <>
      <Img src={avatar_url} />
      <h1>{name}</h1>
    </>
  );
}


The custom useFetch hook is simply a (tiny) wrapper around the useQuery hook offered by the react-query library.


Upon initial render of User, the profile info (name and avatar URL) is not yet available and needs to be fetched. useFetch (which in turns invokes useQuery) initiates a network request and a resulting promise is thrown that will be caught by the outer Suspense boundary in listing 2, and the designated fallback component (a loading indicator) is rendered on the page instead. The component is now suspended as it waits for its data.


With User suspended React continues to render the Repos and Followers components, both of which also need to fetch data. They each are suspended within their own (inner) Suspense boundary.


When the profile info is ready, React (re)renders User to display its content. At this point, Repos and / or Followers may still be suspended, in which case their respective fallbacks are shown. If however their data dependencies were resolved as well, the entire profile view is now complete and fully rendered.

Once a component data has been fetched it is typically cached for future use.

In the current example, after the data (profile info, repositories and followers) for a user has been loaded, it will be cached and returned whenever that user is selected to display, and the components will not suspend. This may change, e.g. if the cached data becomes stale and needs to refetched.


Nested Suspense Boundaries

The last paragraph above contains a crucial insight: by nesting Suspense boundaries in this manner, the developer can orchestrate component loading sequences on a granular level and in an order that matches the desired user experience.


In the current example, the critical content to display as soon as possible is the profile info, while repositories and followers are deemed less important (and may potentially take longer to load). For comparison, let’s rewrite listing 2 without nested Suspense boundaries:

// listing 4.
<Suspense fallback={<Fallback />}>
  <User user={currentUser} />
  <Repos endpoint={`${BASE_URL}/${currentUser}/repos`} />
  <Followers endpoint={`${BASE_URL}/${currentUser}/followers`} />
</Suspense>


In this scenario, React (and the single Suspense boundary) would await all three components before resuming rendering, displaying their contents at the same time and thereby prohibiting the profile info to be rendered first if possible. By wrapping specific components in their own Suspense boundaries, this prioritization is easily achieved.


Fetching Images

The User component in listing 3 renders a Github user’s avatar via a custom Img component instead of rendering an <img src={avatar_url}> tag directly. The requirement is that the user’s name and avatar be loaded and shown together as a unit; the avatar image must also be considered a component resource to wait for and User will suspend until both the profile info and avatar image have been fetched.


The useQuery hook in the react-query library delegates to the developer to provide a function that actually initiates a network request to fetch a desired resource and then returns a promise; in the current example, the Axios library is used for fetching component data:

// listing 5: src/ex05/fetch.js

async function doFetch(uri) {
  const result = await axios(uri)
  return result.data;
}


To fetch an image however requires a slightly different approach:

// listing 6: src/ex05/fetch.js

function doImageFetch(src) {
  return new Promise(resolve => {
    const img = document.createElement('img');
    img.src = src;
    img.onload = () => {
      resolve(src)
    }
  });
}


To reiterate, irrespective of what resource is to be fetched, the mechanism through which components communicate to React when to suspend and resume rendering is via promises.



Transitioning Between Visual States

The code for rendering the initial list of Github users is as follows:

// listing 7: src/ex05/App.js

users.map(user => (
  <a
    key={user}
    onClick={() => selectUser(user)}
  >
    {/* display pending indicator if loading currentUser. */}
    {user} {(isPending && currentUser === user) && '...'}
  </a>
))


When a user is selected, a state variable that tracks the “current user” is updated, which then enables a “pending” indicator to be displayed next to the selected user. Notice however the presence of an isPending flag; where does it originate from?


If the current example is run in the browser, one notices that the profile view isn’t rendered immediately when a user is selected (which would show the outer Suspense boundary’s fallback); the transition from the user list view in listing 7 to the profile view in listing 2 is delayed.


This ability to remain in one visual state for a small amount of time before transitioning to another state typically provides for a much better user experience, as the next state may be “prepared” in the background and then simply switched to, without having to show an undesirable loading spinner or other fallback.


Let’s see what a so-called transition entails:

// listing 8: src/ex05/App.js

const [currentUser, setCurrentUser] = useState(null);
const [showProfile, setShowProfile] = useState(false);

const [startTransition, isPending] = useTransition({
  timeoutMs: 5000
});

const selectUser = user => {
  setCurrentUser(user);

  // initiate a "transition", i.e. prepare for switching to the profile view, in the background.
  startTransition(() => {
    // start to fetch user profile information.
    preFetch(`${BASE_URL}/${user}`);

    // perform a low-priority state update to begin rendering the profile view, as part of the background work.
    setShowProfile(true);
  })
};


The selectUser function, called when a user is selected, does the following:

  • Updates the currentUser state variable and triggers a rerender of the current component (App).

  • Begins a transition via the startTransition function returned from the built-in useTransition hook.


A transition enables the developer to specify a low-priority state update, which allows React to start rendering the next visual state in-memory, as background work. React provides the isPending flag to signal that this work is being performed.


In this case, the low-priority state update applies to the showProfile state variable, which when set to true means that the profile view - the next visual state - should be rendered.

The ability to render several state updates concurrently is core to concurrent mode; see the introduction to concurrent mode for more information).


As part of the transition, the user profile info is also prefetched; more on this in the section Prefetching Data below.


As the currentUser state variable has been updated to reflect the selected user, and the isPending flag is true due to the transition being prepared in the background, the pending indicator in listing 7 can now be displayed.


Updating the showProfile state variable as part of the transition results in the current component - App, shown below by combining listings 2, 7 and 8 - being rendered in-memory (and in parallel to what is currently being displayed, namely the list of Github users and the pending indicator):

// listing 9: src/ex05/App.js

function App() {
  const [currentUser, setCurrentUser] = useState(null);
  const [showProfile, setShowProfile] = useState(false);

  const [startTransition, isPending] = useTransition({
  timeoutMs: 5000
  });

  const selectUser = user => {
    setCurrentUser(user);

    // initiate a "transition", i.e. prepare for switching to the profile view, in the background.
    startTransition(() => {
      // start to fetch user profile information.
      preFetch(`${BASE_URL}/${user}`);

      // perform a low-priority state update to begin rendering the profile view, as part of the background work.
      setShowProfile(true);
    })
  };

  // create a fallback component for the outer Suspense boundary; when not rendering the profile view, set it to null, as rendering the list of users doesn't suspend.
  const Fallback = () => showProfile
    ? `Loading ${currentUser}...`
    : null;

  return (
    <Suspense fallback={<Fallback />}>    
      {showProfile
        ? (
          <>
            <User user={currentUser} />
            
            <Suspense fallback={`Loading repos...`}>
              <Repos endpoint={`${BASE_URL}/${currentUser}/repos`} />
            </Suspense>

            <Suspense fallback={`Loading followers...`}>
              <Followers endpoint={`${BASE_URL}/${currentUser}/followers`} />
            </Suspense>
          </>
        )
        : users.map(user => (
          <a
            key={user}
            onClick={() => selectUser(user)}
          >
            {user} {(isPending && currentUser === user) && '...'}
          </a>
        ))}
    </Suspense>
  )
}


As the showProfile flag is set to true, React will proceed to render the profile view, with its components and nested Suspense boundaries, just as described earlier in this post. Again, this all occurs in the background.


Peruse the following sections in the React documentation to learn more about transitions:


Transition Timeout

If the profile view being rendered in the background completes in a short amount of time, React will directly switch over to it. If it takes longer, perhaps due to the User component’s profile info being fetched over a slow network, the transition may time out to show the outer Suspense boundary fallback. The timeout is specified in milliseconds and passed to the useTransition hook; in the current example it’s set to 5000 ms.


Prefetching Data

It was noted earlier that when starting the transition to render the profile view, the data required by the User component - the profile info - is prefetched, i.e. the network request is sent before the in-memory rendering of the next visual state even begins.


Prefetching is possible in this case due to the fact that the API endpoint for the selected user is known beforehand. Therefore, in an ideal scenario, when the User component is first rendered, its data may already have been fetched and thus it needn’t suspend. Imagine a scenario where simply hovering over a user kicks off fetching the corresponding data; if the user is then actually clicked / selected to show the profile view, the data will be ready and the transition instantaneous.


Prefetching is closely related to the Render-as-you-fetch data fetching pattern detailed in the React documentation, which compares various traditional approaches - such as fetching data in useEffect - to utilizing Suspense.



Summary

React Suspense is poised to become the essential method for loading components’ asynchronous resources in the near future. The aim of this post has been to demonstrate, through a complete example that implements Suspense in conjunction with a 3rd party data fetching library, that the tools to build more robust web applications with better user experiences are available now.




Marc Kleftermarc@remotifi.com



JavaScript seem to be disabled in your browser.

You must have JavaScript enabled in your browser to utilize the functionality of this website.