Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
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:
use() hook consuming a promise, triggering Suspense automaticallyuseTransition to keep the current UI visible while new data loads (avoiding flash of loading state)ErrorBoundary + Suspense for complete async state handlingisPending state for showing a non-blocking loading indicatorReact.lazy and Suspensefallback instead of the suspended subtree.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.useTransition wraps state updates so Suspense shows the old UI with a pending indicator instead of the fallback.| 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 |
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} />;
}use<T>(promise: Promise<T>) returns T - TypeScript correctly infers the resolved type.React.lazy expects the import to return { default: ComponentType }. Named exports need a wrapper: lazy(() => import('./Foo').then(m => ({ default: m.Foo }))).fallback is typed as ReactNode and accepts null (renders nothing while loading).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 useTransition to keep showing current content, or use useDeferredValue for derived values.
Suspense does not catch event handler errors - Only rendering suspensions are caught. An async function in onClick does not trigger Suspense. Fix: Manage event-handler async state manually or move the data fetch to a suspending resource.
| 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 |
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:
StudyServiceSelector (a Client Component)fallback instead of the suspended subtree.isLoading state with declarative loading boundaries.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.use() correctly infers the resolved type in TypeScript.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.useTransition wraps a state update as non-urgent, keeping the current UI visible while data loads.isPending indicator.fetch() inside the render body creates a new promise every render.<ErrorBoundary fallback={<p>Error</p>}>
<Suspense fallback={<Skeleton />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary><Suspense fallback={<ProductSkeleton />}>
<ProductDetails productId={id} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
</Suspense>ProductDetails loads.ProductReviews.const Foo = lazy(() =>
import("./Foo").then((m) => ({ default: m.Foo }))
);React.lazy expects { default: ComponentType } from the import.default.loading.tsx files create route-level Suspense boundaries automatically.onClick does not trigger Suspense.fallback is typed as ReactNode and accepts null, which renders nothing while loading.null is appropriate when you want no visual loading indicator (e.g., prefetched data that resolves instantly).useTransition and useDeferredValue for keeping UI responsiveReviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥