React Suspense, when used for data fetching, lets your UI declare that it needs data and then suspend rendering until that data is ready.
Here’s a look at Suspense in action, fetching user data and rendering it.
// dataService.js
const cache = new Map();
export function fetchUserData(userId) {
if (cache.has(userId)) {
return cache.get(userId);
}
const promise = fetch(`/api/users/${userId}`).then(res => {
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res.json();
}).then(data => {
cache.set(userId, data);
return data;
});
// Store the promise itself to indicate loading
cache.set(userId, promise);
throw promise; // Throw the promise to trigger Suspense
}
// UserProfile.jsx
import { fetchUserData } from './dataService';
import { useSuspense } from './useSuspense'; // Assume a hook that handles promise resolution
function UserProfile({ userId }) {
const userData = useSuspense(fetchUserData(userId)); // This hook handles the promise
return (
<div>
<h1>{userData.name}</h1>
<p>Email: {userData.email}</p>
</div>
);
}
// App.jsx
import { Suspense } from 'react';
import UserProfile from './UserProfile';
function App() {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile userId="123" />
</Suspense>
);
}
The core idea is a contract between the component and the rendering system. The component declares its data requirements by throwing a promise. React catches this promise, pauses the component’s rendering, and shows a fallback UI defined in a Suspense boundary. When the promise resolves, React resumes rendering the component with the fetched data. This decouples data fetching logic from the UI rendering, leading to cleaner code and better perceived performance.
The mental model is that components don’t wait for data; they throw their data requirements. The Suspense boundary acts as a waiter. It observes which components inside it are "suspending" and displays a shared fallback. When a suspended component’s data is ready (its promise resolves), it’s allowed to render. If multiple components suspend, the closest Suspense boundary handles the fallback.
The fetchUserData function, in this pattern, doesn’t return data directly. Instead, it returns a promise that will eventually resolve to the data. Crucially, it throws this promise immediately. This is the signal to React’s Suspense mechanism that data is being fetched and the component should be suspended. The cache prevents redundant fetches for the same userId. The useSuspense hook (a simplified concept here, in reality, you’d use libraries like react-query or swr which have built-in Suspense support) is responsible for unwrapping the promise once it resolves, allowing the UserProfile component to receive the actual userData.
When you have nested Suspense boundaries, the outermost boundary’s fallback is shown if any component within it suspends. This means you can have granular loading states. For instance, a list of items might have its own Suspense boundary showing "Loading items…", while each individual item within that list might have its own inner Suspense boundary showing "Loading item details…". If an item’s details are ready but the list is still loading, only the item will render with its details, while the "Loading items…" fallback remains.
The real magic happens when you consider concurrent rendering. Suspense allows React to start rendering other parts of your application while waiting for data for a specific component. It can even render multiple routes or components concurrently, prioritizing what’s ready and what’s needed. This means a user might see updated content in one section of your app while a less critical section is still waiting for its data, leading to a much more responsive experience than traditional loading spinners that block the entire page.
A common pitfall is mixing Suspense-enabled data fetching with non-Suspense data fetching within the same Suspense boundary. If a component inside a Suspense boundary uses useEffect with fetch and useState to manage loading states, it will not trigger the Suspense fallback. React’s Suspense only reacts to promises thrown directly during the render phase. This can lead to components rendering, then showing their own internal loading spinners, while the Suspense fallback remains hidden, creating a confusing and inconsistent user experience. Always ensure all data fetching within a Suspense boundary is Suspense-compatible.
The next hurdle is managing server-side rendering with Suspense, which involves hydrating the client-side application with pre-fetched data.