React

React Suspense👨‍💻

Suspense is React's way of saying "this component isn't ready yet -- show something else while we wait." It replaces the imperative if (isLoading) return <Spinner /> pattern with a declarative boundary that catches pending operations and renders a fallback automatically. What started as a code-splitting tool in React 16 has grown into the foundation for data fetching, streaming server rendering, and coordinated loading states across your entire component tree.

Key Takeaways

  • 1Suspense works by catching promises thrown during render — when a child component suspends, React walks up the tree to the nearest `<Suspense>` boundary and displays its `fallback` prop
  • 2React.lazy() was the first Suspense-compatible API (React 16.6): it defers component imports until first render, enabling route-based and feature-based code splitting
  • 3The `use()` hook (React 19) reads promises directly in render, making Suspense work for data fetching in client components without third-party wrappers
  • 4In streaming SSR, everything outside Suspense boundaries is the "shell" — it ships immediately while suspended sections stream in as their data resolves
  • 5Nesting Suspense boundaries controls loading granularity: one boundary per section means independent loading, one boundary wrapping multiple components means they reveal together
  • 6Suspense pairs with Error Boundaries — Suspense handles the loading state, Error Boundaries handle the failure state, and together they replace the `{ data, isLoading, error }` triple

Master react suspense

Take the React Intermediate course with hands-on lessons and challenges.

Examples

Route-based code splitting with lazy()

tsx

Each page becomes a separate chunk that downloads on navigation. The Admin route preloads on hover, so if the user moves their cursor to the link and then clicks, the chunk is already cached. This is the highest-impact code splitting pattern because routes are natural loading boundaries.

Data fetching with use() — React 19

tsx

The use() hook reads a promise during render. When the promise is pending, the component suspends and React shows the nearest Suspense fallback. When it resolves, React re-renders with the data. The key rule: create or cache the promise outside the suspending component to avoid re-creating it on every render attempt.

Nested boundaries for granular loading

tsx

Each dashboard section loads independently: if the revenue chart query takes 2 seconds but stat cards resolve in 200ms, users see the stats immediately. The outer boundary catches the header, while inner boundaries control each widget. Skeletons that match the actual component layout prevent layout shift when content appears.

Streaming server rendering (Next.js)

tsx

With streaming SSR, the server sends the HTML shell (layout, navigation, skeletons) immediately. As each async Server Component resolves, its HTML is streamed to the browser and swapped in for the skeleton — no client-side JavaScript needed for the initial content. The slow reviews query no longer blocks the entire page.

Suspense + Error Boundary — the complete pattern

tsx

Error Boundary wraps Suspense because a rejected promise needs to be caught somewhere. Without the Error Boundary, a failed fetch crashes the entire app. With it, users see a retry button scoped to just that section. This Suspense + ErrorBoundary pair is the replacement for the old { data, isLoading, error } pattern — each concern is handled by a dedicated component boundary.

Suspense with TanStack Query — the pragmatic choice

tsx

In production, most teams use TanStack Query or SWR instead of raw use() because they handle caching, deduplication, and background refetching. useSuspenseQuery removes the isLoading/error boilerplate entirely — the component only runs when data is available. useSuspenseQueries fires multiple requests in parallel under a single Suspense boundary.

Common Mistakes

Mistake:

Creating the promise inside the suspending component — e.g., calling `fetch()` directly in the component that calls `use()`. Every time React retries rendering after a suspend, it creates a new promise, causing an infinite loop.

Fix:

Create or cache the promise in a parent component, a route loader, or a data library. Pass it down as a prop or use a library like TanStack Query that manages promise lifecycle for you. The rule: the component that suspends should receive its promise, not create it.

Mistake:

Wrapping every small component in its own Suspense boundary. A page with 10 separate spinners popping in at different times is worse than a single skeleton that resolves once.

Fix:

Group related content under a single Suspense boundary so they reveal together. Use separate boundaries only for sections that are visually independent and have meaningfully different load times — a sidebar versus the main content area, not a title versus a subtitle.

Mistake:

Using Suspense without an Error Boundary. If the suspended promise rejects (network error, 500 response), there's nothing to catch it and the error propagates up, potentially crashing the entire app.

Fix:

Always pair Suspense with an Error Boundary. The Error Boundary wraps the Suspense boundary (outside it, not inside) so it catches both render errors and rejected promises. Provide a retry mechanism in the error fallback.

Mistake:

Expecting `useEffect` fetches or regular async/await in client components to trigger Suspense. They don't — Suspense only activates when a component throws a promise during render, which only happens with `use()`, `React.lazy()`, or Suspense-enabled libraries.

Fix:

For client-side data fetching with Suspense, use `useSuspenseQuery` from TanStack Query, the `use()` hook with a cached promise, or SWR's suspense mode. Plain `useEffect` + `useState` patterns bypass Suspense entirely.

Best Practices

  • Design skeleton fallbacks that match the actual component layout — same dimensions, same grid structure. A spinner tells users nothing about what's coming; a skeleton sets the right expectation and prevents layout shift when content appears.
  • Place Suspense boundaries at meaningful UI seams: page-level for route transitions, section-level for independent content areas (sidebar, main feed, comments). Avoid boundaries around individual elements unless they represent a genuinely separate loading concern.
  • Preload lazy-loaded routes on link hover or viewport intersection. Call `import('./pages/Checkout')` on `onMouseEnter` — the module is cached by the bundler, so the second call during render resolves instantly.
  • Use `useSuspenseQueries` (TanStack Query) or start multiple promises in a parent component to fetch data in parallel. Nesting Suspense boundaries with sequential fetches creates waterfalls — the inner component's fetch doesn't start until the outer one resolves.
  • In streaming SSR, put fast static content outside Suspense boundaries (the shell) and slow data-dependent sections inside them. The shell ships immediately, giving users something to read while dynamic content streams in.

Summary

Suspense is React's declarative primitive for handling asynchronous operations. It started with code splitting via `React.lazy()` in React 16, expanded to data fetching with the `use()` hook in React 19, and powers streaming SSR in frameworks like Next.js. The core mechanic is always the same: a component suspends by throwing a promise, the nearest Suspense boundary shows a fallback, and React re-renders when the promise resolves. Pair every Suspense boundary with an Error Boundary, design skeletons that match your real UI, and use boundary placement to control whether content loads independently or reveals together.

Practice React with hands-on challenges

Learn react suspense hands-on in your IDE

Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.

Related Concepts

Related Cheatsheets

Master React with Stanza

Interactive lessons and challenges, right in your code editor.

Check the free courses. No credit card.