Suspense Boundaries — Declaratively handle loading states for async components and data
Recipe
import { Suspense } from "react";
// Wrap async components in Suspense boundaries
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
);
}
// React 19: use() hook for promise-based data
import { use } from "react";
function Dashboard({ dataPromise }: { dataPromise: Promise<DashboardData> }) {
const data = use(dataPromise);
return <DashboardView data={data} />;
}When to reach for this: When components need to wait for async data, lazy-loaded code, or server-streamed content. Suspense replaces manual isLoading state with declarative loading boundaries.
Working Example
import { Suspense, use, useState, useTransition, lazy, type ReactNode } from "react";
// --- Data fetching with use() and Suspense ---
interface Post {
id: number;
title: string;
body: string;
}
// Cache for fetch promises (simple example — use a library in production)
const cache = new Map<string, Promise<Post[]>>();
function fetchPosts(userId: number): Promise<Post[]> {
const key = `posts-${userId}`;
if (!cache.has(key)) {
cache.set(
key,
fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch posts");
return res.json();
})
);
}
return cache.get(key)!;
}
function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
const posts = use(postsPromise);
return (
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.id} className="border rounded-lg p-4">
<h3 className="font-semibold">{post.title}</h3>
<p className="text-gray-600 mt-1">{post.body}</p>
</li>
))}
</ul>
);
}
// Skeleton loader
function PostListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="border rounded-lg p-4 animate-pulse">
<div className="h-5 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-5/6 mt-1" />
</div>
))}
</div>
);
}
// --- Page with multiple Suspense boundaries ---
function UserDashboard() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
const handleUserChange = (id: number) => {
startTransition(() => {
setUserId(id);
});
};
return (
<div className="max-w-2xl mx-auto p-6">
<nav className="flex gap-2 mb-6">
{[1, 2, 3].map((id) => (
<button
key={id}
onClick={() => handleUserChange(id)}
className={`px-4 py-2 rounded ${
userId === id ? "bg-blue-600 text-white" : "bg-gray-100"
} ${isPending ? "opacity-50" : ""}`}
>
User {id}
</button>
))}
</nav>
<ErrorBoundary fallback={<p className="text-red-600">Failed to load posts.</p>}>
<Suspense fallback={<PostListSkeleton />}>
<PostList postsPromise={fetchPosts(userId)} />
</Suspense>
</ErrorBoundary>
</div>
);
}
// --- Lazy loading with Suspense ---
const Settings = lazy(() => import("./Settings"));
const Analytics = lazy(() => import("./Analytics"));
function AppRoutes() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}What this demonstrates:
- React 19
use()hook consuming a promise, triggering Suspense automatically useTransitionto keep the current UI visible while new data loads (avoiding flash of loading state)- Paired
ErrorBoundary+Suspensefor complete async state handling isPendingstate for showing a non-blocking loading indicator- Lazy code splitting with
React.lazyand Suspense
Deep Dive
How It Works
- When a component inside a Suspense boundary suspends (throws a promise), React shows the
fallbackinstead of the suspended subtree. - Once the promise resolves, React retries rendering the component with the resolved data.
use()(React 19) reads the value from a promise. If the promise is not yet resolved, it suspends the component.React.lazy()wraps a dynamic import and suspends until the module loads.useTransitionwraps state updates so Suspense shows the old UI with a pending indicator instead of the fallback.- Next.js Server Components use Suspense boundaries for streaming — the server sends the fallback first, then streams the resolved content.
Parameters & Return Values
| API | Parameters | Purpose |
|---|---|---|
<Suspense> | fallback: ReactNode | Shows fallback while children suspend |
use(promise) | Promise<T> | Reads promise value, suspends if pending |
use(context) | Context<T> | Reads context (can be called conditionally in React 19) |
React.lazy(loader) | () => Promise<{ default: Component }> | Code-splits a component |
useTransition() | None | Returns [isPending, startTransition] for non-blocking updates |
startTransition(fn) | () => void | Marks state updates as non-urgent |
Variations
Nested Suspense for progressive loading:
function ProductPage({ productId }: { productId: string }) {
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={productId} />
{/* Reviews load independently, later */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
</Suspense>
);
}Server Components streaming with Suspense (Next.js):
// app/page.tsx — Server Component
export default function Page() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
{/* This async server component streams when ready */}
<RevenueChart />
</Suspense>
</main>
);
}
async function RevenueChart() {
const data = await getRevenueData(); // runs on server
return <Chart data={data} />;
}TypeScript Notes
use<T>(promise: Promise<T>)returnsT— TypeScript correctly infers the resolved type.React.lazyexpects the import to return{ default: ComponentType }. Named exports need a wrapper:lazy(() => import('./Foo').then(m => ({ default: m.Foo }))).- Suspense
fallbackis typed asReactNodeand acceptsnull(renders nothing while loading).
Gotchas
-
Creating promises during render — Calling
fetch()inside a component body creates a new promise every render, causing an infinite suspend loop. Fix: Create the promise outside the render (in an event handler, parent, or cache) and pass it as a prop. -
Missing ErrorBoundary — If a suspending promise rejects, the error propagates up. Without an error boundary, the entire tree unmounts. Fix: Always pair Suspense with an ErrorBoundary.
-
Waterfall loading — Nested Suspense boundaries with sequential data fetches cause waterfalls (A loads, then B starts). Fix: Start fetches in parallel and pass promises down, or use a data library that supports parallel preloading.
-
Flash of loading state — Quick data fetches cause a brief flash of the skeleton. Fix: Use
useTransitionto keep showing current content, or useuseDeferredValuefor derived values. -
Suspense does not catch event handler errors — Only rendering suspensions are caught. An async function in
onClickdoes not trigger Suspense. Fix: Manage event-handler async state manually or move the data fetch to a suspending resource.
Alternatives
| Approach | Trade-off |
|---|---|
Suspense + use() | Declarative, composable; requires promise caching discipline |
useEffect + loading state | Manual but explicit; verbose boilerplate |
| React Query / SWR | Full caching, revalidation, Suspense opt-in; extra dependency |
Next.js loading.tsx | Route-level Suspense boundary; Next.js-specific |
| Skeleton UI without Suspense | CSS-only approach; no React integration |
Real-World Example
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Study landing page with Suspense
// File: src/app/study/page.tsx
import { Suspense } from "react";
import StudyServiceSelector from "@/components/study/study-service-selector";
export default async function StudyLandingPage() {
const availableServices = await getAvailableStudyServices();
const services = availableServices.map((service) => ({
slug: service.serviceSlug,
title: service.serviceTitle,
platform: service.platform,
basicsCount: service.categories.find((c) => c.category === "basics")?.cardCount || 0,
featuresCount: service.categories.find((c) => c.category === "features")?.cardCount || 0,
bestPracticesCount: service.categories.find((c) => c.category === "best-practices")?.cardCount || 0,
}));
const totalCards = availableServices.reduce((sum, s) => sum + s.totalCards, 0);
return (
<div className="relative min-h-screen">
<Suspense fallback={<div>Loading services...</div>}>
<StudyServiceSelector services={services} totalCards={totalCards} />
</Suspense>
</div>
);
}What this demonstrates in production:
- The data is fetched above the Suspense boundary in the async Server Component
- Suspense here primarily handles the client hydration boundary for
StudyServiceSelector(a Client Component) - Data is pre-transformed on the server (mapping and reducing) before passing to the client component, minimizing serialized payload
- The fallback should match the expected layout size to prevent CLS. In production, replace the text fallback with a skeleton component
- Suspense boundaries can be nested. A page-level Suspense shows a full-page loader, while section-level Suspense shows localized spinners
FAQs
What does a Suspense boundary do?
- When a component inside a Suspense boundary suspends (throws a promise), React shows the
fallbackinstead of the suspended subtree. - Once the promise resolves, React retries rendering the component with the resolved data.
- Suspense replaces manual
isLoadingstate with declarative loading boundaries.
How does the use() hook work with Suspense in React 19?
function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
const posts = use(postsPromise);
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}use()reads the value from a promise. If the promise is not yet resolved, it suspends the component.- The nearest Suspense boundary shows its fallback until the promise resolves.
use()correctly infers the resolved type in TypeScript.
What is the difference between React.lazy and use() for Suspense?
React.lazy()wraps a dynamic import and suspends until the module (component code) loads. It is for code splitting.use()reads data from a promise and suspends until the data resolves. It is for data fetching.- Both trigger Suspense, but they serve different purposes.
How does useTransition prevent the flash of loading state?
useTransitionwraps a state update as non-urgent, keeping the current UI visible while data loads.- Instead of immediately showing the Suspense fallback, React shows the old content with an
isPendingindicator. - This avoids jarring skeleton flashes for fast data fetches.
Gotcha: Why does creating a promise inside a component body cause an infinite loop?
- Calling
fetch()inside the render body creates a new promise every render. - The new promise suspends the component, which triggers a re-render, which creates another new promise.
- Fix: create the promise outside the render (in an event handler, parent, or cache) and pass it as a prop.
Gotcha: What happens if a Suspense promise rejects without an ErrorBoundary?
- The error propagates up the React tree. Without an error boundary, the entire tree unmounts.
- Fix: always pair Suspense with an ErrorBoundary above or around it.
<ErrorBoundary fallback={<p>Error</p>}>
<Suspense fallback={<Skeleton />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary>How does nested Suspense enable progressive loading?
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={id} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
</Suspense>- The outer boundary shows its fallback until
ProductDetailsloads. - The inner boundary independently shows its own fallback for
ProductReviews. - Critical content loads first; secondary content streams in later.
How do you type React.lazy for a named export in TypeScript?
const Foo = lazy(() =>
import("./Foo").then((m) => ({ default: m.Foo }))
);React.lazyexpects{ default: ComponentType }from the import.- For named exports, transform the import result to wrap the named export as
default.
How does Suspense work with Next.js Server Components for streaming?
- The server sends the Suspense fallback HTML first, then streams the resolved content when the async Server Component finishes.
- This allows the browser to render the page progressively without waiting for all data.
- In Next.js,
loading.tsxfiles create route-level Suspense boundaries automatically.
Can Suspense catch errors from event handlers or async onClick functions?
- No. Suspense only catches rendering suspensions (thrown promises during render).
- An async function in
onClickdoes not trigger Suspense. - Fix: manage event-handler async state manually or move the data fetch to a suspending resource.
What is the Suspense fallback type and can it be null?
fallbackis typed asReactNodeand acceptsnull, which renders nothing while loading.- Using
nullis appropriate when you want no visual loading indicator (e.g., prefetched data that resolves instantly). - For a good user experience, prefer skeleton components that match the expected layout size to prevent CLS.
Related
- Error Boundaries — Pair with Suspense for complete async handling
- Performance —
useTransitionanduseDeferredValuefor keeping UI responsive - State Machines — Alternative approach to modeling loading/error/success states