<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Bitovi Blog - UX and UI design, JavaScript and Front-end development

How to Use React Suspense to Improve Your UI Load Time

Travis Draper

React Suspense is a great way to improve the load time of your UI. In this post, learn about Suspense and why you should implement it in your next project.

posted in React on September 7, 2022 by Travis Draper


How to Use React Suspense to Improve Your UI Load Time

Travis Draper by Travis Draper

If you work regularly with React, chances are you are already familiar with at least the concept of React Suspense. Suspense is a useful tool provided by the React library that allows developers more control over UI loading states. If, however, you work all day with legacy code, refuse to let your React version budge past 16.5, or are simply new to React, allow us to give you an introduction! 

What is Suspense?

React Suspense is not React’s foray into the film industry—Suspense is a tool provided by the React library beginning with React 16.6. Suspense essentially extends React’s reactive capabilities into the asynchronous world, allowing the developer the ability to implement a more declarative loading state for their UI. What does that mean? Well, let’s dive into the code snippets.

Suspense and Code-Splitting (React 16.6)

When React Suspense first released as an experimental feature in React 16.6, its only supported use was for code-splitting with React.lazy. Take the following, for example:

import Dashboard from '../pages/Dashboard'
import Employees from '../pages/Employees'
import ProjectRoutes from '../pages/Projects/Routes'

export default function App(): JSX.Element {
  return (
    <Layout>        
      <Switch>          
        <Route path="/" exact>            
          <Dashboard />          
        </Route>          
        <Route path="/team-members">            
          <Employees />          
        </Route>          
        <Route path="/projects">            
          <ProjectRoutes />          
        </Route>        
      </Switch>    
    </Layout>
  )
}

Let’s say each of these children are large components with deeply nested trees. Historically, mounting these components would take time and slow down your initial load time. This provides a poor UX. We would rather the user be able to see the page as soon as possible. If you update this implementation by wrapping the children of <Layout /> in a <Suspense /> boundary, the user sees the rendered output of the <Spinner /> component and whatever view is presented within <Layout> until the bundles of the components are loaded and rendered.

import { lazy } from 'react'

const Employees = lazy(() => import("../pages/Employees"));
const Dashboard = lazy(() => import("../pages/Dashboard"));
const ProjectRoutes = lazy(() => import("../pages/Projects/Routes"));

export default function App(): JSX.Element {  
  return (    
    <Layout>      
      <Suspense fallback={<Spinner />}>        
        <Switch>          
          <Route path="/" exact>            
            <Dashboard />          
          </Route>          
          <Route path="/team-members">            
            <Employees />          
          </Route>          
          <Route path="/projects">            
            <ProjectRoutes />          
          </Route>       
        </Switch>      
      </Suspense>    
    </Layout>  
  );
}

Notice the updated implementation for the imports of our components as well. We're leveraging React.lazy to render a dynamic import() as a regular component, automatically loading the bundles for these separate components when App first renders.

React expects these imports to return a Promise that resolves to a module containing a component as its default export. Under the hood, React knows to treat this Promise as the cue for Suspense to fire and render the fallback component. Depending on the size of your application, such a pattern could be a great way to speed up the load times.

Suspense and Data-Fetching

This is all well and good, but the real asynchronous bread-and-butter are our API calls, right? The React team remains hesitant to encourage idiosyncratic implementations of Suspense with data fetching. Why is this? Well, below is a code snippet for a resource, something React uses under the hood to trigger its Suspense boundaries.

function wrapPromise(promise) {  
  let status = "pending";  
  let result;  
  let suspender = promise.then(    
    (r) => {      
      status = "success";      
      result = r;    
    },    
    (e) => {      
      status = "error";      
      result = e;    
    }  
  );  
  return {    
    read() {      
      if (status === "pending") {        
        throw suspender;      
      } else if (status === "error") {        
        throw result;      
      } else if (status === "success") {        
        return result;      
        }    
      }  
    };
  }

This wrapper takes a Promise and returns an object with a read() method, and that is the basic shape of a resource. This read() method will either return the result, throw the error, or throw the Promise. It's that latter possibility that is essential: whenever we throw a Promise, the most recent parent Suspense boundary will catch it and return the fallback.

Looking at the above implementation, we see two callback functions passed to the .then() chained to our Promise argument. The former fires on success and the latter fires on error, and until either of those callbacks fire, our status is pending, and so our function throws its Promise. This implementation then executed within a component may look like this:

import { wrapPromise } from ‘./utils’

function fetchUser() {  
  return new Promise((resolve) => {    
    setTimeout(() => {      
      resolve({        
        name: "John Doe"      
      });    
    }, 1000);  
  });
}

function fetchProfileData() {  
  let userPromise = fetchUser();  
  return {    
    user: wrapPromise(userPromise),  
  };
}

// need to call this outside of your render function
const resource = fetchProfileData();

function ProfileDetails = () => {  

// Try to read user info, although it might not have loaded yet 
const user = resource.user.read();  
return <h1>{user.name}</h1>;
}

To note, we use fetchUser to simulate our server request and then wrap that Promisified return in wrapPromise to give it the Suspense resource shape. React knows under the hood to call this read() method to determine when to trigger a component rerender. If we call this component wrapped in Suspense within a parent component, React will render the <Spinner /> component until the read() method no longer throws a Promise!

Function ProfilePage = () => {  
  return (
    <ErrorBoundary>
      <Suspense fallback={<Spinner />}        
        <ProfileDetails />      
      </Suspense>
   </ErrorBoundary>  
  )
}

Once the return of read() changes, this will trigger a re-render, and React will render with the result of user within <ProfileDetails />. If, however, the result of read() is a thrown error, that's a great opportunity to implement an <ErrorBoundary> to catch it within your UI! 

As of version 18, React only officially supports the implementation of Suspense with code-splitting through React.lazy. For this reason, it's encouraged for users not to implement their own custom data fetching patterns that leverage React Suspense, as its implementation may change in the future.

Alternatively, users can work with existing libraries that integrate React Suspense under the hood, and should the Suspense implementation details change in the future this may have a more minimal impact on the user’s own codebase.

Data fetching libraries that optionally integrate with Suspense include SWR, React Query, and Relay. Note that the Suspense section in each library urges caution around implementing Suspense with data fetching in production. Use at your own caution! 

Suspense with React 18

With React 18, Suspense saw its use cases expanded. As I said before, ad hoc data fetching with Suspense or integrated 3rd party libraries in production is still a caution, but this doesn’t stop Suspense from finessing its DOM handling as well as finding new value in server side rendering (SSR). Take the following example:

<div>    
  <Suspense fallback={<Spinner />}>      
    <Panel>        
      <Comments /> // ← This throws a promise while fetching      
    </Panel>   
  </Suspense>
</div>

 

In earlier versions of Suspense, React would have crawled the tree, rendering <Panel> and then began to render <Comments>, seen that <Comments> initiated a fetch and threw a Promise to engage Suspense, and so placed a "hole" within <Panel> where <Comments /> would be.

Thereafter, <Panel> would be hidden from view with a display: none styling, and its useEffect would fire because it had technically mounted. Once the data had been fetched, any fallback UI provided by Suspense would be removed from the DOM, <Comments /> would be placed within <Panel> and the display: none style would be removed from <Panel>.

In React 18, Suspense simplifies this process. Rather than putting the <Panel> content into the DOM, we throw it and everything between the nearest parent <Suspense> away. Once <Comments /> is ready, everything will be rendered. Incomplete trees are no longer committed. React waits for the whole subtree to be ready and then commits it at once. This has the added benefit of not triggering any useEffects within <Panel> while <Comments /> suspends.

Furthermore, engaging Suspense in React 18 serves as an “opt-in” for two exciting SSR features: HTML Streaming and Selective Hydration. Take a look at the following images:

suspense-1 suspense-2

Imagine the box in the lower right was wrapped in a Suspense. With SSR and Suspense, the remainder of the HTML content for the page can stream in while the fallback Suspense UI renders in place of that particularly slow component. Once the content has loaded, it can be dynamically added into the HTML via a discrete <script> tag in place of the fallback Suspense, meaning that our HTML streaming does not need to be a top-down process. You no longer need to wait for all data to load on the server before sending it. As long as you have enough content to show a skeleton of your application, the remaining HTML can be piped in as it is read! 

Moreover, with SSR, the HTML content will load from your server for the page before the JavaScript code. This means the page, or portions of the page, may be visible but not interactive. These non-interactive portions are “dry”, meaning they lack the logic to make them work, so they need to be “hydrated.”

Whenever we wrap a component in Suspense, React can carry on streaming in available HTML and hydrating, all while the suspended components wait to be ready. In the below images, the green shapes are hydrated HTML. You can see how the streamed-in HTML hydrates while another component suspends. Once that component’s HTML loads, it streams in and is ready to hydrate as well!

suspense-3 suspense-4-1

The magic doesn’t stop there! Imagine a scenario in which the HTML for two components loads. Let’s say the sidebar (pictured as “hydrating” in the image below) is hydrating first as it is higher up in the tree, but the user clicks elsewhere on a still non-interactive portion of the page. React will actually be able to synchronously hydrate this dry portion of the page during the capture phase of that click event, and as a result, the dry section will be hydrated in time to respond to the user interaction!

Thereafter, React will continue hydrating the sidebar. Selective Hydration allows React to prioritize hydrating the most important parts of the page as per user interaction. In combination with HTML Streaming, Selective Hydration means the page feels interactive as fast as possible.

suspense-5

Benefits of Incorporating Suspense

React Suspense offers developers the ability to more declaratively handle their UI loading states. Gone are the days of if (loading) return <Loading /> or if (error) return <Error /> within components. We can leverage Suspense (and Error Boundary, as well) to reduce boilerplate code in our UI, streamline the data fetching process, and granularly batch view states together.

const App = () => {  
  return (    
    <>      
      <Navbar />      
      <Suspense fallback={<AppLoading />}>        
        <Profile />
        <Friends />        
        <Suspense fallback={<WidgetLoading />}>          
          <Widget />          
          <Comments />
        </Suspense
        <Suspense fallback={<CarouselLoading />}>
          <Carousel />
        </Suspense>
      </Suspense>
      <Toolbar />
    </>
  );
};

 

Now we’ve orchestrated a view state where the user only sees <Navbar /> and <AppLoading /> and <Toolbar /> until all data has been fetched within <Profile /> and <Friends />. Thereafter, we'll see <WidgetLoading /> and <CarouselLoading />, and these loading states will resolve as their respective children finish their data fetching. Components further down the tree not wrapped in additional Suspense, like <Toolbar /> are free to render while its siblings suspend.

Suspense is a powerful convenience tool for helping us developers shape cleaner code and provide more responsive applications. Nevertheless, its implementation may still be in flux, so the React team advises us to use caution implementing Suspense in our codebases for anything other than code-splitting. 

Below is a simple demo of React Suspense in action! Feel free to play around with its delay feature or peruse the implementation details to gather a better understand of the Suspense mechanism at work!

 

 

 

Is Suspense too suspenseful for you?

Bitovi has expert React consultants eager to support your project. Schedule your free consult call to unlock solutions to optimize your ReactJS project!

 

Create better web applications. We’ll help. Let’s work together.