React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

suspenseloadingstreamingasyncreact-patterns

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
  • useTransition to keep the current UI visible while new data loads (avoiding flash of loading state)
  • Paired ErrorBoundary + Suspense for complete async state handling
  • isPending state for showing a non-blocking loading indicator
  • Lazy code splitting with React.lazy and Suspense

Deep Dive

How It Works

  • When a component inside a Suspense boundary suspends (throws a promise), React shows the fallback instead 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.
  • useTransition wraps 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

APIParametersPurpose
<Suspense>fallback: ReactNodeShows 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()NoneReturns [isPending, startTransition] for non-blocking updates
startTransition(fn)() => voidMarks 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>) 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 }))).
  • Suspense fallback is typed as ReactNode and accepts null (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 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.

Alternatives

ApproachTrade-off
Suspense + use()Declarative, composable; requires promise caching discipline
useEffect + loading stateManual but explicit; verbose boilerplate
React Query / SWRFull caching, revalidation, Suspense opt-in; extra dependency
Next.js loading.tsxRoute-level Suspense boundary; Next.js-specific
Skeleton UI without SuspenseCSS-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 fallback instead of the suspended subtree.
  • Once the promise resolves, React retries rendering the component with the resolved data.
  • Suspense replaces manual isLoading state 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?
  • useTransition wraps 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 isPending indicator.
  • 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 ProductDetails loads.
  • 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.lazy expects { 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.tsx files 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 onClick does 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?
  • fallback is typed as ReactNode and accepts null, which renders nothing while loading.
  • Using null is 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.
  • Error Boundaries — Pair with Suspense for complete async handling
  • PerformanceuseTransition and useDeferredValue for keeping UI responsive
  • State Machines — Alternative approach to modeling loading/error/success states