React Suspense
October 31, 2025
Overview
React Suspense for data fetching lets you declaratively show a fallback UI while React waits for data—without manually juggling isLoading flags across your component tree. In React 18+, Suspense works with streaming SSR, Server Components, and data libraries (Relay, React Query, SWR) to deliver fast, incremental UIs.
In most React apps, loading states sprawl: each component fetches data, tracks isLoading, renders a spinner, and forwards that state down to children. As the tree grows, those flags multiply, and it’s easy to block too much UI—or forget to handle an error path. Suspense flips this: components declare what they need, and a parent boundary declares what to show while any child is waiting. You get centralized loading UX without wiring booleans through half your app.
Scope: this guide focuses on client-side Suspense for data fetching—where to place boundaries and how they interact with SSR/streaming—not on building a full data layer.
What Suspense Guarantees
- If something below a boundary isn’t ready, React pauses rendering there and shows the fallback.
- When the data is ready, React reveals the content without tearing the rest of the page.
- If something fails, the error bubbles to the nearest ErrorBoundary—no scattered
try/catchin every hook.
What Suspense Does (and Doesn’t)
- Does: pause rendering at a boundary until the data needed by its children is ready, then reveal the content.
- Doesn’t: fetch data by itself. You still use a data source (custom resource, Relay, React Query/SWR with Suspense enabled, RSC).
- Pairs with:
ErrorBoundary, streaming SSR,SuspenseList,useTransitionfor progressive hydration/UX.
When to Use Suspense (and When Not To)
| Situation | Recommendation | Why | Suggested pattern / alternative |
|---|---|---|---|
| Page composed of clear sections (route, sidebar, main, widgets) | Use Suspense | Progressively reveal sections instead of blocking the whole page | Section-level boundaries with shared skeletons; consider SuspenseList |
| Streaming SSR (e.g., Next.js App Router) | Use Suspense | Boundaries enable streamed HTML for faster first content | Place boundaries at route/segment level; pair with ErrorBoundary |
| 80% of loading UI can use reusable skeletons | Use Suspense | Consistent UX with minimal custom code | Build a small skeleton system (CardSkeleton, ListSkeleton, TextLine, etc.) |
| First load of a slow list/grid | Use Suspense | Good perceived performance with skeletons | Boundary around the list only; default list/grid skeleton |
| Subsequent refetch of the same view | Prefer minimal Suspense | Avoid flashing back to skeletons | “Keep previous data” + small “Updating…” indicator (useTransition or data-lib isFetching) |
| Need to keep old content visible during data change | Minimize/skip | Fallbacks would hide useful context | useTransition to defer state; inline status chip/spinner; optimistic UI |
| Highly bespoke loaders per component | Skip | Boundary/fallback sprawl with little reuse | Explicit isLoading per component; consolidate designs or reduce variations |
| Ultra-interactive controls (inputs, menus, small widgets) | Skip | Fallbacks feel glitchy for tiny interactions | Disable control + inline spinner; defer network on blur/submit |
| Dashboard with multiple independent panels | Use Suspense | One slow panel won’t block others | Multiple boundaries; orchestrate with SuspenseList (e.g., revealOrder="forwards") |
| Centralized error handling desired | Use Suspense | Errors thrown during data read bubble cleanly | Wrap boundaries with ErrorBoundary per section |
| Legacy SSR without streaming | Maybe / often skip | Less benefit without streaming reveal | Classic spinners with data-lib states; consider upgrading SSR pipeline |
| Simple one-off fetch in a small component | Maybe / often skip | Suspense may be overkill | Plain useEffect + isLoading; upgrade to Suspense if it grows |
Examples
Snippets illustrate the pattern; in production, prefer a data library (Relay / React Query / SWR) over a hand-rolled resource, and always pair each boundary with an
ErrorBoundary.
Core Pattern: A “Resource” That Suspends
// lib/resource.js
// Minimal "wrap a Promise" pattern that Suspense can pause on
export function createResource(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; // <- makes Suspense show fallback
if (status === 'error') throw result; // <- bubbles to ErrorBoundary
return result;
}
};
}
// App.jsx
import React, { Suspense } from 'react';
import { createResource } from './lib/resource';
const usersResource = createResource(fetch('/api/users').then(r => r.json()));
function Users() {
const users = usersResource.read(); // will suspend until resolved
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
function App() {
return (
<Suspense fallback={<div>Loading users…</div>}>
<Users />
</Suspense>
);
}
export default App;
In real apps, you’ll likely use a library (Relay, React Query with suspense: true, SWR) rather than hand-rolled resources.
Error Handling with Error Boundaries
// ErrorBoundary.jsx
import React from 'react';
export class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
render() {
if (this.state.error) return <div role="alert">Failed: {this.state.error.message}</div>;
return this.props.children;
}
}
// Wrap Suspense with ErrorBoundary
<ErrorBoundary>
<Suspense fallback={<div>Loading…</div>}>
<Users />
</Suspense>
</ErrorBoundary>
Suspense With Data Libraries
React Query (TanStack Query)
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const qc = new QueryClient({ defaultOptions: { queries: { suspense: true } } });
root.render(
<QueryClientProvider client={qc}>
<App />
</QueryClientProvider>
);
// Users.tsx
import { useQuery } from '@tanstack/react-query';
export function Users() {
const { data } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(r => r.json()) });
return <ul>{data.map((u: any) => <li key={u.id}>{u.name}</li>)}</ul>;
}
// App.tsx
import { Suspense } from 'react';
export default function App() {
return (
<Suspense fallback={<div>Loading users…</div>}>
<Users />
</Suspense>
);
}
SWR (with Suspense)
// Profile.jsx
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(r => r.json());
export function Profile() {
const { data } = useSWR('/api/me', fetcher, { suspense: true });
return <div>{data.name}</div>;
}
Relay (Built for Suspense)
Relay’s hooks (e.g., usePreloadedQuery) suspend by default when data isn’t ready, and integrate tightly with streaming SSR.
Streaming SSR & Route-Level Boundaries
With React 18’s streaming SSR (and frameworks like Next.js App Router), you can place multiple Suspense boundaries in a page. The server streams HTML as each boundary resolves, so users see content progressively.
// Page.tsx (Next.js App Router)
import { Suspense } from 'react';
import { Products } from './Products';
import { Sidebar } from './Sidebar';
export default function Page() {
return (
<>
<Suspense fallback={<div>Loading sidebar…</div>}>
<Sidebar />
</Suspense>
<Suspense fallback={<div>Loading products…</div>}>
<Products />
</Suspense>
</>
);
}
Coordinating Multiple Boundaries: SuspenseList
import { Suspense, SuspenseList } from 'react';
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<SkeletonA />}><PanelA /></Suspense>
<Suspense fallback={<SkeletonB />}><PanelB /></Suspense>
<Suspense fallback={<SkeletonC />}><PanelC /></Suspense>
</SuspenseList>
revealOrder="forwards": show in order as each becomes ready.tail="collapsed": hide subsequent fallbacks to reduce layout shifts.
Transitions for Smoother UI
Use useTransition to keep the current UI visible while kicking off a data fetch that will suspend.
import React, { Suspense, useState, useTransition } from 'react';
function SearchResults({ query }) {
const resource = getSearchResource(query); // returns a suspending resource
const results = resource.read();
return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}
export default function Search() {
const [query, setQuery] = useState('react');
const [pending, startTransition] = useTransition();
function onChange(e) {
const q = e.target.value;
startTransition(() => setQuery(q)); // suspends without janking the UI
}
return (
<>
<input onChange={onChange} placeholder="Search…" />
{pending && <span>Updating…</span>}
<Suspense fallback={<div>Loading results…</div>}>
<SearchResults query={query} />
</Suspense>
</>
);
}
Approach Comparison: Suspense, Data Libraries, and useEffect
| Approach | Best For | Pros | Cons |
|---|---|---|---|
| Suspense + Relay | GraphQL apps needing SSR streaming & fine-grained boundaries | Tight Suspense integration; preloading; great perf | Requires Relay runtime & GraphQL schema |
| Suspense + React Query / SWR | REST or GraphQL with flexible caching | Toggle suspense: true; caching, retries, revalidation | Must coordinate error boundaries; hydration nuances |
| Server Components (RSC) | Static/read-mostly data & streaming HTML | 0 KB JS for server-only parts; built-in streaming | Interactivity requires client components |
useEffect + local state | Small components; simple fetches | Explicit control; library-free | Manual isLoading/error plumbing; harder SSR/streaming |
Fallback Strategy: UX Trade-offs
| Fallback Pattern | Pros | Cons | Notes |
|---|---|---|---|
| Global page spinner | Simple; minimal code | Big perceived delay; no content until all ready | Use only for full-page fetches |
| Section skeletons | Faster perceived load; reduces layout shift | More components to maintain | Place boundaries around logical sections |
| Progressive reveal (SuspenseList) | Guided reveal order; stable layout | More orchestration | Great for dashboards & feeds |
Common Pitfalls (and Fixes)
| Pitfall | Why it happens | Fix |
|---|---|---|
| Throwing non-Promise values | Resource throws something Suspense can’t handle | Throw the pending Promise to suspend; throw Error for failures |
| No ErrorBoundary | Errors bubble and crash the tree | Wrap Suspense with an ErrorBoundary |
| Global boundary only | Entire page blocked by one slow request | Use multiple boundaries; skeletons for sections |
| Mismatch in SSR hydration | Data on server & client out of sync | Use the same data cache on both; dehydrate/rehydrate (React Query/Relay) |
| Refetch loops with transitions | State updates retrigger fetch repeatedly | Memoize keys; debounce inputs; guard effects |
Testing Suspense UIs
- Unit: Use React Testing Library’s await screen.findByText(...) to wait for reveal.
- Error paths: Simulate rejected promises; assert ErrorBoundary content.
- Loading UX: Assert fallbacks show first, then content appears.
- Integration (SSR): Verify streamed chunks (framework-dependent) and hydration without warnings.
Alternatives & When Not to Use Suspense
- Simple components / fetch-on-click: Plain useEffect may be clearer.
- Legacy SSR stacks without streaming: Suspense benefits shrink; consider library spinners.
- Non-React data consumers: If many clients share an API, keep a standard API layer.
Conclusion
Suspense brings declarative loading states and progressive rendering to React’s core model. Use it with multiple boundaries, ErrorBoundary components, and transitions for smooth experiences; pair with Relay, React Query, SWR, or Server Components to fetch efficiently. Start small—wrap one slow section with a Suspense boundary—and grow from there.

