We're transitioning Studio from Beta to Early Availability

React Suspense - A complete guide

This article explains how to use React Suspense to build smoother, faster, and more user-friendly React applications.
Joel Olawanle

Written by Joel Olawanle

Jun 24, 2024
React Suspense - A complete guide

The React team regularly releases new features to help make our applications more efficient and manageable. One such feature is React Suspense.

By the end of this article, you'll understand how to use React Suspense to build smoother, faster, and more user-friendly React applications.

#What is React Suspense, and how does it work?

React Suspense is a built-in feature that simplifies managing asynchronous operations in your React applications.

Unlike data-fetching libraries like Axios or state management tools like Redux, Suspense focuses solely on managing what is displayed while your components wait for asynchronous tasks to complete.

#How React Suspense works

When React encounters a Suspense component, it checks if any child components are waiting for a promise to resolve. If so, React "suspends" the rendering of those components and displays a fallback UI, such as a loading spinner or message, until the promise is resolved.

Here's an example to illustrate how Suspense works:

<Suspense fallback={<div>Loading books...</div>}>
<Books />
</Suspense>

In this code snippet, until the data for Books is ready, the Suspense component displays a fallback UI, in this case, a loading message. This clarifies to the user that the content is being fetched, providing a more seamless experience.

The fallback UI can be a paragraph, a component, or anything you prefer.

How it works with server-side rendering

React Suspense also enhances server-side rendering (SSR) by allowing you to render parts of your application progressively.

With SSR, you can use renderToPipeableStream to load essential parts of your page first and progressively load the remaining parts as they become available. Suspense manages the fallbacks during this process, improving performance, user experience, and SEO.

#Data fetching patterns in React

When a React component needs data from an API, there are three common data fetching patterns: fetch on render, fetch then render, and render as you fetch (which is what React Suspense facilitates). Each pattern has its strengths and weaknesses.

Let's explore these patterns with examples to understand their nuances better.

Fetch on render

In this approach, the network request is triggered inside the component after it has mounted. This straightforward pattern can lead to performance issues, especially with nested components making similar requests.

const UserProfile = () => {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(response => response.json())
.then(data => setUser(data));
}, []);
if (!user) return <p>Loading user profile...</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
};

In this example, the fetch request is triggered in the useEffect hook after the component mounts. The loading state is managed by checking if the user data is available.

This approach can lead to a network waterfall effect, where each subsequent component waits for the previous one to fetch data, causing delays.

Fetch then render

The fetch-then-render approach initiates the network request before the component mounts, ensuring data is available as soon as the component renders.

This pattern helps avoid the network waterfall problem seen in the on-render approach.

const fetchUserData = () => {
return fetch('/api/user')
.then(response => response.json());
};
const App = () => {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUserData().then(data => setUser(data));
}, []);
if (!user) return <p>Loading user data...</p>;
return (
<div>
<UserProfile user={user} />
</div>
);
};
const UserProfile = ({ user }) => (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);

In this example, the fetchUserData function is called before the component mounts, and the data is set in the useEffect hook. The loading state is managed similarly by checking if the user data is available.

This method starts fetching early but still waits for all promises to be resolved before rendering useful data, which can lead to delays if one request is slow.

Render as you fetch

React Suspense introduces the render-as-you-fetch pattern, allowing components to render immediately after initiating a network request.

This improves user experience by rendering UI elements as soon as data is available, without waiting for all data to be fetched.

const fetchUserData = () => {
let data;
let promise = fetch('/api/user')
.then(response => response.json())
.then(json => { data = json });
return {
read() {
if (!data) {
throw promise;
}
return data;
}
};
};
const resource = fetchUserData();
const App = () => (
<Suspense fallback={<p>Loading user profile...</p>}>
<UserProfile />
</Suspense>
);
const UserProfile = () => {
const user = resource.read();
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
};

In this example, the fetchUserData function starts fetching data immediately and returns an object with a read method. The read method throws a promise if the data isn't ready, which triggers Suspense to show the fallback UI. Once available, read returns the data, and the component renders it.

This pattern allows each component to manage its loading state independently, reducing wait times and improving the application's overall responsiveness.

#Use cases of Suspense

React Suspense can significantly enhance your applications' performance and user experience. Here are some practical use cases where Suspense shines:

1. Data fetching

One of the primary use cases of Suspense is managing data fetching in your applications. Using Suspense, you can display a loading state while fetching data from an API, providing a smoother user experience.

For example, the code below shows how the DataComponent fetches data and displays a loading message until the data is available.

const fetchData = () => {
let data;
let promise = fetch('/api/data')
.then(response => response.json())
.then(json => { data = json });
return {
read() {
if (!data) {
throw promise;
}
return data;
}
};
};
const resource = fetchData();
const App = () => (
<Suspense fallback={<p>Loading data...</p>}>
<DataComponent />
</Suspense>
);
const DataComponent = () => {
const data = resource.read();
return (
<div>
<h1>Data: {data.value}</h1>
</div>
);
};

The fetchData function initiates a fetch request and returns an object with a read method. If the data isn't ready, the read method throws a promise, which tells Suspense to display the fallback UI ("Loading data..."). Once available, read returns the data, and DataComponent renders it.

2. Lazy loading components

Suspense works seamlessly with React's lazy() function to load components only when needed, reducing your application's initial load time. This is especially useful for large applications where not all components are required immediately.

For example, here is a LazyComponent that is dynamically imported and lazy-loaded using React.lazy():

const LazyComponent = React.lazy(() => import('./LazyComponent'));
const App = () => (
<Suspense fallback={<p>Loading component...</p>}>
<LazyComponent />
</Suspense>
);

In this code, the <Suspense> component specifies a fallback message ("Loading component...") to display while the LazyComponent is being fetched and loaded.

3. Handling multiple asynchronous operations

Suspense can manage multiple asynchronous operations, ensuring that each part of the UI displays its loading state independently. This is useful in scenarios where different parts of the application fetch data from different sources.

const fetchUserData = () => {
let data;
let promise = fetch('/api/user')
.then(response => response.json())
.then(json => { data = json });
return {
read() {
if (!data) {
throw promise;
}
return data;
}
};
};
const fetchPostsData = () => {
let data;
let promise = fetch('/api/posts')
.then(response => response.json())
.then(json => { data = json });
return {
read() {
if (!data) {
throw promise;
}
return data;
}
};
};
const userResource = fetchUserData();
const postsResource = fetchPostsData();
const App = () => (
<div>
<Suspense fallback={<p>Loading user...</p>}>
<UserProfile />
</Suspense>
<Suspense fallback={<p>Loading posts...</p>}>
<Posts />
</Suspense>
</div>
);
const UserProfile = () => {
const user = userResource.read();
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
};
const Posts = () => {
const posts = postsResource.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
};

In this code, there are two asynchronous operations: fetching user data and fetching posts data. Each fetch function returns an object with a read method that throws the promise if the data isn't ready.

The App component uses two <Suspense> components, each with its own fallback UI ("Loading user..." and "Loading posts..."). This setup allows the UserProfile and Posts components to manage their loading states independently, improving the application’s overall responsiveness.

You can also nest <Suspense> components to manage rendering order with Suspense:

const App = () => (
<div>
<Suspense fallback={<p>Loading user profile...</p>}>
<UserProfile />
<Suspense fallback={<p>Loading posts...</p>}>
<Posts />
</Suspense>
</Suspense>
</div>
);

This way, the outer <Suspense> component wraps the UserProfile component, displaying a fallback message ("Loading user profile...") while fetching user profile data, ensuring its details are shown first. Inside this outer <Suspense>, another <Suspense> wraps the Posts component with its fallback message ("Loading posts..."), ensuring posts details render only after the user profile details are available.

This setup effectively manages loading states. It displays the outer fallback message until user profile data is fetched and then handles the post data loading state with the nested fallback message.

4. Server-side rendering (SSR)

Suspense can improve SSR by allowing you to specify which parts of the app should be rendered on the server and which should wait until the client has more data. This can significantly enhance the performance and SEO of your web application.

For example, the code below shows an App component that uses <Suspense> to specify a fallback UI ("Loading...") while the MainComponent is being loaded.

import { renderToPipeableStream } from 'react-dom/server';
const App = () => (
<Suspense fallback={<p>Loading...</p>}>
<MainComponent />
</Suspense>
);
// Server-side rendering logic
const { pipe } = renderToPipeableStream(<App />);

The renderToPipeableStream function from react-dom/server handles the server-side rendering, ensuring that the initial HTML sent to the client is rendered quickly and additional data is loaded progressively.

#How to use React Suspense

So far, we've gone over how React Suspense works and explored various scenarios with code examples. Now, let's apply what we've learned to a live project.

For this quick demo, we'll fetch blog posts from Hygraph (a headless CMS that leverages GraphQL to serve content to your applications) into a React application (styled with Tailwind CSS) and use Suspense to display skeleton post animations until the posts are loaded.

undefined

Step 1: Clone a Hygraph project

First, log in to your Hygraph dashboard and clone the Hygraph “Basic Blog" starter project. You can also choose any project from the marketplace that suits your needs.

Next, go to the Project settings page, navigate to Endpoints, and copy the High-Performance Content API endpoint. We'll use this endpoint to make API requests in our React project.

Step 2: Set up data fetching logic

When building a React application, especially one that deals with asynchronous data fetching, it's essential to manage the state of your data requests efficiently.

To do this, handle asynchronous and data-fetching operations in two files within the api directory. This separation of concerns makes our code easier to read, test, and maintain. It also allows us to reuse the data-fetching logic across multiple components, promoting code reusability.

In your React project, create a folder named api in the root directory. This folder will contain the files for handling data fetching and promise management.

Create two files in the api folder: wrapPromise.js and fetchData.js.

wrapPromise.js

The wrapPromise.js file contains a function designed to handle the state of a promise, which is an object representing the eventual completion or failure of an asynchronous operation.

export default function wrapPromise(promise) {
let status = 'pending';
let result;
const 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;
}
},
};
}

The function tracks a promise's state using status ('pending', 'success', or 'error') and result (the resolved value or error). The suspender handles the promise's resolution or rejection, updating the status and result accordingly.

The returned object includes a read method that React's Suspense uses to check the promise's state: it throws the suspender if pending, the error if failed, or returns the result if successful.

fetchData.js

The fetchData.js file contains a function to perform the data fetching from the Hygraph GraphQL API.

import wrapPromise from './wrapPromise';
function fetchData(url, query) {
const promise = fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
})
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP error! Status: ${res.status}`);
}
return res.json();
})
.then((data) => data.data)
.catch((error) => {
console.error('Fetch error:', error);
throw error;
});
return wrapPromise(promise);
}
export default fetchData;

The fetchData function first imports wrapPromise to manage the state of fetched data. It then constructs a POST request using the provided API endpoint (url) and GraphQL query (query).

Any errors during the fetch are caught, logged, and rethrown. Finally, the function returns the promise wrapped by wrapPromise, enabling Suspense to handle its state.

Step 3: Create the React components

Now, let's create the React components that use Suspense to fetch and display data from Hygraph.

First, create a components folder inside the src folder. Within this components folder, create a file named Posts.jsx. This file handles making requests to the Hygraph API and displaying the fetched data.

Ensure you create a .env file at the root of your project and add the Hygraph API endpoint.

import fetchData from '../../api/fetchData';
const query = `
{
posts {
id
slug
title
excerpt
coverImage {
url
}
publishedAt
author {
name
picture {
url
}
}
}
}
`;
const resource = fetchData(import.meta.env.VITE_HYGRAPH_API, query);
const Posts = () => {
const { posts } = resource.read();
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
{posts.map((currentPost) => (
<div className="relative h-[440px] mb-5" key={currentPost.id}>
<img
className="w-full h-52 object-cover rounded-lg"
src={currentPost.coverImage.url}
alt=""
/>
<h2 className="text-xl font-semibold my-4">{currentPost.title}</h2>
<p className="text-gray-600 mb-2">{currentPost.excerpt}</p>
<div className="flex justify-between items-center absolute bottom-0 w-full">
<div className="flex items-center">
<img
className="w-10 h-10 rounded-full object-cover mr-2"
src={currentPost.author?.picture.url}
alt=""
/>
<p className="text-sm text-gray-600">{currentPost.author?.name}</p>
</div>
<p className="text-sm text-gray-600">
{new Date(currentPost.publishedAt).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</p>
</div>
</div>
))}
</div>
);
};
export default Posts;

In the code above, we import the fetchData function from our previously created api folder. This function handles the data fetching process. We then define a GraphQL query to fetch posts.

Using the fetchData function, we pass the API endpoint (from the .env file) and the query to fetch the data. The function returns a resource object that we can use to read the data when it's ready.

In the Posts component, we call resource.read() to obtain the data. This method is designed to integrate with React Suspense, allowing our component to wait for the data to be fetched. Once the data is available, we map over the posts and render them in a grid layout, displaying each post's title, excerpt, cover image, and author information.

Next, in the App.jsx file, we import the Posts.jsx file and implement React Suspense:

import { Suspense } from 'react';
import Posts from './components/Posts';
import PostSkeleton from './components/PostSkeleton';
const App = () => {
return (
<div className="container mx-auto mt-24 px-5">
<div className="text-5xl font-semibold my-10">
<h1>Blog</h1>
</div>
<Suspense fallback={<PostSkeleton />}>
<Posts />
</Suspense>
</div>
);
};
export default App;

This setup ensures that users see a loading skeleton instead of a blank screen while the data is being fetched. Let’s create the skeleton.

In the components folder, create a PostSkeleton.jsx file and add the following code:

const PostSkeleton = () => {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
{[...Array(5)].map((_, index) => (
<div key={index} className="relative h-[400px] mb-5 animate-pulse">
<div className="w-full h-52 bg-gray-200 rounded-lg"></div>
<div className="h-6 bg-gray-200 rounded mt-4 w-3/4"></div>
<div className="h-4 bg-gray-200 rounded mt-2 w-5/6"></div>
<div className="h-10 bg-gray-200 rounded mt-2 w-5/6"></div>
<div className="flex justify-between items-center absolute bottom-0 w-full mt-4">
<div className="flex items-center">
<div className="w-10 h-10 bg-gray-200 rounded-full mr-2"></div>
<div className="h-4 bg-gray-200 rounded w-20"></div>
</div>
<div className="h-4 bg-gray-200 rounded w-16"></div>
</div>
</div>
))}
</div>
)
}
export default PostSkeleton

This component generates a grid of skeleton cards using the animate-pulse class for a loading animation. This skeleton structure helps maintain a consistent layout and provides a better user experience during data fetching.

When you load the React application, you notice the Skeleton displays until the data is fetched from Hygraph.

#Handling errors

We need to implement an error boundary to ensure our application handles errors. This component catches any errors during data fetching or rendering, preventing the entire app from crashing and providing a user-friendly error message.

First, create an ErrorBoundary.jsx file in the components folder. This component acts as a wrapper around other components, catching errors in its child component tree.

import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static defaultProps = {
fallback: <h1>Something went wrong.</h1>,
};
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.log(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export default ErrorBoundary;

Next, we must modify App.jsx to wrap the Suspense component with the ErrorBoundary. This setup ensures that the error boundary captures errors occurring during data fetching or rendering.

import { Suspense, lazy } from 'react';
import Posts from './components/Posts';
import PostSkeleton from './components/PostSkeleton';
import ErrorBoundary from './components/ErrorBoundary';
const App = () => {
return (
<div className="container mx-auto mt-24 px-5">
<div className="text-5xl font-semibold my-10">
<h1>Blog</h1>
</div>
<ErrorBoundary fallback={<div>Failed to fetch data!</div>}>
<Suspense fallback={<PostSkeleton />}>
<Posts />
</Suspense>
</ErrorBoundary>
</div>
);
};
export default App;

With this setup, our application can handle errors. For instance, if you tamper with the API URL in Posts.jsx or any other issue during data fetching, the error boundary catches the error and displays the message "Failed to fetch data!" instead of crashing the entire app.

This ensures a better user experience even when something goes wrong in the background.

#Wrapping up

React Suspense is a powerful tool that simplifies handling asynchronous operations in React. By leveraging Suspense, you can improve your applications' performance and user experience.

Don't forget to sign up for a free-forever developer account on Hygraph to explore more about integrating React with a headless CMS.

Blog Author

Joel Olawanle

Joel Olawanle

Joel Olawanle is a Frontend Engineer and Technical writer based in Nigeria who is interested in making the web accessible to everyone by always looking for ways to give back to the tech community. He has a love for community building and open source.

Share with others

Sign up for our newsletter!

Be the first to know about releases and industry news and insights.