React Suspense for Data Fetching with the use() Hook
Learn how to use React's Suspense boundaries and the use() hook to handle async data fetching with elegant loading and error states.
What you'll learn
- ✓How Suspense works for data fetching
- ✓The use() hook and how it reads promises
- ✓Structuring loading and error boundaries
- ✓Streaming patterns with nested Suspense
- ✓Practical examples with fetch and context
Prerequisites
- •Comfortable with React components
- •Basic understanding of Promises
React Suspense lets you declaratively specify what to show while async data is loading. Instead of tracking loading booleans and rendering spinners manually, you wrap components in a <Suspense> boundary and let React handle the rest. The use() hook, introduced in React 19, is the key to reading promises inside components.
The Core Idea
Suspense inverts the loading state pattern. Instead of each component managing its own isLoading flag, a component simply reads data as if it were already available. If the data is not ready, React “suspends” the component and shows the nearest <Suspense> boundary’s fallback.
import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserProfile userId={42} />
</Suspense>
);
}
When UserProfile suspends because its data is not ready, React renders the fallback. When the data arrives, React replaces the fallback with the actual component.
The use() Hook
The use() hook reads the value from a promise. If the promise is still pending, the component suspends. If it resolved, the hook returns the value. If it rejected, the nearest error boundary catches the error.
import { use } from 'react';
function UserProfile({ userId }) {
const userPromise = fetchUser(userId);
const user = use(userPromise);
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
There is an important subtlety here. You should not create the promise inside the rendering component, because each render would create a new promise, causing an infinite suspend loop. Instead, create the promise in a parent component or a cache and pass it down.
Passing Promises as Props
The recommended pattern is to start the fetch in a parent component (or a Server Component) and pass the promise as a prop.
import { Suspense } from 'react';
function DashboardPage() {
// Start fetching immediately, don't await
const userPromise = fetchUser();
const statsPromise = fetchStats();
return (
<div className="dashboard">
<Suspense fallback={<UserSkeleton />}>
<UserCard userPromise={userPromise} />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel statsPromise={statsPromise} />
</Suspense>
</div>
);
}
function UserCard({ userPromise }) {
const user = use(userPromise);
return <div className="card">{user.name}</div>;
}
function StatsPanel({ statsPromise }) {
const stats = use(statsPromise);
return (
<div className="stats">
<p>Posts: {stats.postCount}</p>
<p>Followers: {stats.followers}</p>
</div>
);
}
Both fetches start simultaneously. Each component independently suspends and resolves. The user sees whichever data arrives first, without either blocking the other.
Error Boundaries for Rejected Promises
When a promise passed to use() rejects, React throws the error to the nearest error boundary. You can use the standard ErrorBoundary pattern or a library like react-error-boundary.
import { ErrorBoundary } from 'react-error-boundary';
function App() {
const dataPromise = fetchData();
return (
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<Suspense fallback={<p>Loading...</p>}>
<DataView dataPromise={dataPromise} />
</Suspense>
</ErrorBoundary>
);
}
The boundary catches the rejection and renders the fallback. You can add a retry button by using the resetErrorBoundary function.
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Failed to load: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
use() with Context
The use() hook is not limited to promises. It can also read React context, and unlike useContext, it can be called conditionally.
import { use, createContext } from 'react';
const ThemeContext = createContext('light');
function ThemedButton({ isSpecial }) {
if (isSpecial) {
const theme = use(ThemeContext);
return <button className={`btn-${theme}`}>Special</button>;
}
return <button>Normal</button>;
}
This is valid because use() does not follow the rules of hooks. It can appear inside conditions, loops, and early returns. This flexibility is intentional and makes it more versatile than useContext.
Nested Suspense for Streaming
You can nest <Suspense> boundaries to create a streaming experience where parts of the page load progressively.
function ProductPage({ productId }) {
const productPromise = fetchProduct(productId);
const reviewsPromise = fetchReviews(productId);
const relatedPromise = fetchRelated(productId);
return (
<div>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productPromise={productPromise} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews reviewsPromise={reviewsPromise} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts relatedPromise={relatedPromise} />
</Suspense>
</Suspense>
</div>
);
}
The product details appear first. Reviews and related products stream in as their data arrives. Each section has its own skeleton, and none blocks the others.
Caching Promises
To avoid re-fetching on every render, cache your promises. React itself caches promises passed to use() during a single render pass, but across navigations or re-mounts, you need your own cache or a library like TanStack Query.
const cache = new Map();
function fetchWithCache(url) {
if (!cache.has(url)) {
cache.set(url, fetch(url).then(r => r.json()));
}
return cache.get(url);
}
function UserList() {
const usersPromise = fetchWithCache('/api/users');
const users = use(usersPromise);
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
For production applications, use a proper caching layer rather than a bare Map. Libraries like TanStack Query, SWR, or framework-level caches handle invalidation, deduplication, and garbage collection.
When to Use Suspense for Data Fetching
Use Suspense when you want to eliminate loading state boilerplate, enable parallel data fetching, or build streaming server-rendered pages. It pairs naturally with Server Components, which can pass promises to Client Components as props. For simple, local data fetching in a single component, useEffect with useState still works fine, but Suspense with use() is the direction React is heading.
Related articles
- React React Custom Hooks: Patterns for Reusable Logic
Master React custom hooks with practical patterns for data fetching, form handling, debouncing, and more. Learn when and how to extract reusable logic.
- React React Suspense and useTransition: Async UI Patterns
Learn how to use React Suspense and useTransition to build smooth async UIs. Master concurrent rendering, loading states, and transition patterns.
- React React Higher-Order Components vs Hooks: Migration Guide
Compare Higher-Order Components and hooks in React with side-by-side examples, migration strategies, and guidance on when each pattern still makes sense.
- React React Suspense Orchestration: Advanced Loading Patterns
Master advanced React Suspense patterns including nested boundaries, SuspenseList coordination, transition integration, and skeleton architecture for complex UIs.