Introduction
While Suspense traditionally worked with lazy loading, React 19's use API and Suspense-enabled libraries let you use it for data fetching too.
Key Concepts
Suspense for data works the same as for code - components that need data suspend until it's available, showing a fallback in the meantime.
Real World Context
In modern React apps:
- TanStack Query, SWR, and Relay support Suspense mode
- Server Components can async/await directly
- The
useAPI reads promises in client components - Streaming SSR sends HTML as data becomes available
Deep Dive
Using the use API
React 19's use reads promises in render:
tsximport { use, Suspense } from 'react'; function UserProfile({ userPromise }) { const user = use(userPromise); // Suspends until resolved return <h1>{user.name}</h1>; } // Parent creates the promise (not in render!) function ProfilePage({ userId }) { const userPromise = fetchUser(userId); // Cached or memoized return ( <Suspense fallback={<ProfileSkeleton />}> <UserProfile userPromise={userPromise} /> </Suspense> ); }
With TanStack Query
tsximport { useSuspenseQuery } from '@tanstack/react-query'; function Posts() { const { data: posts } = useSuspenseQuery({ queryKey: ['posts'], queryFn: fetchPosts }); return posts.map(post => ( <article key={post.id}>{post.title}</article> )); } function PostsPage() { return ( <Suspense fallback={<PostsSkeleton />}> <Posts /> </Suspense> ); }
Parallel vs Sequential Loading
Sequential (Waterfall):
tsx<Suspense fallback={<UserSkeleton />}> <User id={userId} /> {/* Loads first */} <Suspense fallback={<PostsSkeleton />}> <Posts userId={userId} /> {/* Waits for User */} </Suspense> </Suspense>
Parallel:
tsx<Suspense fallback={<PageSkeleton />}> <User id={userId} /> {/* Both load simultaneously */} <Posts userId={userId} /> </Suspense>
Server Components + Suspense
In frameworks like Next.js, async Server Components automatically integrate with Suspense:
tsx// Server Component - can await directly async function PostList() { const posts = await db.posts.findMany(); return posts.map(post => <Post key={post.id} {...post} />); } // Page with streaming export default function Page() { return ( <div> <Header /> {/* Sent immediately */} <Suspense fallback={<PostsSkeleton />}> <PostList /> {/* Streamed when ready */} </Suspense> </div> ); }
Common Pitfalls
- Creating promises in render: This causes infinite suspends. Create/cache promises outside the component or use a data library.
- Missing error handling: Suspended promises that reject need an error boundary to catch them.
- Suspense waterfalls: Nested Suspense can cause sequential loading. Lift boundaries up to enable parallel fetching.
Best Practices
- Use data fetching libraries with Suspense support (TanStack Query, SWR)
- Pair every Suspense boundary with an ErrorBoundary
- Start fetching data as early as possible (route loaders, server components)
- Cache or dedupe promises to avoid duplicate fetches
- Consider loading priority - what should the user see first?
- Use skeleton screens that match your actual UI layout
Summary
Suspense for data fetching lets you declaratively handle loading states for async data. Use the use API or Suspense-enabled libraries. Remember to handle errors and avoid creating waterfalls.
Code Examples
tsx
import { Suspense, use } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
// Cache for promises to avoid re-fetching
const cache = new Map<string, Promise<any>>();
function fetchWithCache<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (!cache.has(key)) {
cache.set(key, fetcher());
}
return cache.get(key)!;
}
// Component that suspends
function UserData({ userId }: { userId: string }) {
const userPromise = fetchWithCache(
`user-${userId}`,
() => fetch(`/api/users/${userId}`).then(r => r.json())
);
const user = use(userPromise);
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Error fallback component
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="error">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
// Combined Suspense + Error handling
function UserProfile({ userId }: { userId: string }) {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => cache.delete(`user-${userId}`)}
>
<Suspense fallback={<UserSkeleton />}>
<UserData userId={userId} />
</Suspense>
</ErrorBoundary>
);
}