#Next.js#React

React Suspense and Data Fetching

Suspense makes components waiting for data declarative: instead of manually managing loading state, boundaries show fallbacks automatically. Learn how it works with TanStack Query, streaming SSR with Server Components, boundary design, Error Boundaries, and when imperative async work should not use Suspense.

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

React Suspense and Data Fetching

Anyone who has written React for a few years has probably written code like this: fetch an API, define an isLoading, then keep switching between isLoading ? <Spinner /> : <List /> in JSX. One component is manageable, but after three or five nested layers, loading state tangles together like spaghetti: the parent is waiting, the child is waiting too, and the middle still has to determine “is the data actually empty, or has it not returned yet?”

Suspense, introduced in React 16.6, exists to settle this old debt. It turns “a component is waiting for an asynchronous value” into something declarative: you no longer need to manage loading manually; a Suspense boundary automatically catches the “suspended” state, displays a fallback, and resumes rendering when the data returns.

I. Traditional Loading State: Why We All Need Suspense

First, look at a typical “old-school” implementation:

tsx
function ArticleList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['articles'],
    queryFn: fetchArticles,
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!data || data.length === 0) return <Empty />;

  return <ul>{data.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}

On its own, there is no problem. But when a page consists of multiple independent data blocks, such as an article list, author information, comments, and recommendations, every component must handle its own loading/error/empty states, and the parent then assembles them. The code quickly becomes a pile of conditional branches. Worse still: because these loading states are independent, the page produces “waterfall-like flickering.” First the list spins, then the author section starts spinning after the list returns, and then the comments spin after the author returns. What the user sees is a constantly shifting page.

Suspense's core promise is to turn “waiting for data” from imperative state management into a declarative component-tree expression. You only describe “an asynchronous value is needed here”; React handles showing the fallback while it is not ready.

II. How Suspense Works: Throw a Promise

Suspense's mechanism is elegantly simple: if a child component discovers during rendering that an asynchronous value it depends on is not ready, it throws a promise. After React catches that throw, it walks upward to find the nearest <Suspense> boundary, displays its fallback, and retries rendering that child component once the promise resolves.

tsx
import { Suspense } from 'react';

function Page() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <ArticleList />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </Suspense>
  );
}

The key understanding is this: Suspense is not a “data-fetching library”; it is a protocol for “coordinating asynchronous state.” Anything that can “throw a promise when reading a value that is not ready” can integrate with Suspense: React Query, SWR, Relay, Server Components, or even a resource wrapper you write yourself.

Writing the smallest possible Suspense data source yourself can help you fully understand it:

tsx
type Resource<T> = { read: () => T };

function createResource<T>(promise: Promise<T>): Resource<T> {
  let status: 'pending' | 'success' | 'error' = 'pending';
  let result: T;
  let error: unknown;

  const p = promise.then(
    (r) => { status = 'success'; result = r; },
    (e) => { status = 'error'; error = e; },
  );

  return {
    read() {
      if (status === 'success') return result;
      if (status === 'error') throw error;
      throw p; // 关键:挂起
    },
  };
}

read() throws a promise when the data is not ready. That is all of Suspense's magic. Once you understand this, every library integration that follows is just a wrapper around the same mechanism.

III. Working with React Query / TanStack Query

In real projects, you would not write createResource yourself. Instead, use React Query's Suspense mode. The key API is useSuspenseQuery (note that the old useQuery({ suspense: true }) configuration has been split into a separate hook):

tsx
import { useSuspenseQuery } from '@tanstack/react-query';

function ArticleList() {
  const { data } = useSuspenseQuery({
    queryKey: ['articles'],
    queryFn: fetchArticles,
  });

  // 注意:data 这里一定是有的,不再需要判空和 isLoading
  return <ul>{data.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}

useSuspenseQuery no longer returns isLoading / isError, because its contract is: it either gives you data or suspends. Loading is handled by the outer Suspense boundary, and errors are handled by the outer Error Boundary (covered in the next section). This makes the component code extremely clean: it writes only the happy path where “the data has already returned.”

One often overlooked detail: Suspense in React Query is not concurrency magic. If you initiate multiple queries sequentially in a parent component, they are still serial. What truly makes them concurrent is the “concurrent features + transition” introduced by React 18. Put data fetching inside startTransition, or let React schedule these suspensions in a Server Components scenario, to avoid waterfalls. This is why Suspense and concurrent rendering are always discussed together: it is a consumer of concurrent features.

IV. Streaming SSR: Server Components + Suspense

Where Suspense truly changes the game is streaming SSR. Traditional SSR means “wait until all data is ready, then return the entire HTML,” which leads to a long blank-screen time before the first render. With Server Components + Suspense, Next.js (Turbopack / Next 16 now fully supports this) can:

  1. Stream an HTML shell from the server first: render ready sections directly, and use Suspense fallbacks as placeholders for sections that are not ready.
  2. Let the browser render immediately after receiving the shell, without waiting.
  3. Continue pushing slow sections to the browser as streamed chunks once their server-side data is ready, and let React “fill” them in place.
tsx
// app/dashboard/page.tsx (Next.js Server Component)
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <main>
      <Header />
      <Suspense fallback={<ChartSkeleton />}>
        {/* 这个查询要 800ms,但不会阻塞首屏 */}
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<ListSkeleton />}>
        <RecentOrders />
      </Suspense>
    </main>
  );
}

The mental model needs to change here: in Server Components, “component rendering” itself is a data-fetching process. An async component awaits a database query, and React treats that await as a Suspense suspension point. You do not need to write useQuery; Suspense is provided natively by the framework. This means TTFB (Time To First Byte) can be very low, while the slowest data block determines only the fill time of “its own area,” rather than the first-render time of the entire page.

One practical point: under streaming SSR, loading.tsx (a Next.js convention) is essentially a Suspense boundary. Every loading.tsx you add is equivalent to wrapping that route subtree in a Suspense boundary.

V. Suspense Boundary Strategy: Granularity Takes Skill

The two mistakes beginners make most easily are both related to boundary granularity.

Mistake one: wrap every component in a Suspense boundary. It may look “modular,” but in practice every component suspends and resumes independently. The page ends up with many fallbacks moving at the same time, then disappearing one after another, which creates a terrible experience. The fallbacks are not coordinated either, so the page looks visually twitchy.

Mistake two: wrap the entire page in only one Suspense boundary. This degrades into “all or nothing”: if any child query is slow, the entire page waits on a blank screen. The advantages of streaming SSR are wasted.

The correct approach is to divide boundaries by “content block.” Ask yourself: is this content visually and semantically an independent unit? Can the user accept “this block appearing a little later”? If so, it is an independent Suspense unit.

Rules of thumb:

  • Page framing such as top navigation and breadcrumbs: do not wrap them, or wrap them in the outermost shell. They should be present on the first render.
  • Main content (article body, list): wrap it independently. If it is slow, show a skeleton first.
  • Secondary content (comments, recommendations, sidebar): wrap it independently; it can arrive later.
  • Each item within a list: never wrap it. The granularity is too fine.

VI. Use with Error Boundary: Cover the Async Lifecycle

Suspense only handles loading, not errors. If a suspended promise rejects, it bubbles upward as a JavaScript error, and an Error Boundary catches it. Therefore, a complete asynchronous component lifecycle requires two boundaries working together:

tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function AsyncBlock() {
  return (
    <ErrorBoundary fallback={<ErrorFallback />}>
      <Suspense fallback={<Skeleton />}>
        <ArticleList />
      </Suspense>
    </ErrorBoundary>
  );
}

Their responsibilities are clear:

  • Suspense handles loading: when a subtree suspends, it shows the fallback.
  • Error Boundary handles errors: when a subtree, including a suspended promise, throws, it shows error UI.

A frequently overlooked pitfall: the Error Boundary must be outside Suspense. If the order is reversed, Suspense handles the error as “not ready” first, leading to unpredictable behavior. Remember the order: Error Boundary on the outside, Suspense inside it, and the component at the center. This is the standard sandwich structure for asynchronous components.

Also, if you want to retry after a request failure, remember to use resetKey or pass a key to the Error Boundary to force a remount. Otherwise, React will not automatically clear the error state.

VII. Suspense Is Not Universal: Understand Its Fit Boundaries

Suspense is suitable for render-time data dependencies: cases where “the component needs this data as soon as it renders, and cannot draw without it.” Lists, details, and dashboards are naturally suitable.

But it is not suitable for imperative async work, especially requests triggered by events: clicking “Save” to submit a form, clicking “Search” to start a query, or validating on blur. In these scenarios, the timing of the request has nothing to do with “component rendering,” and you usually need to perform follow-up actions after receiving the result, such as navigation, notifications, or updating other state. Forcing Suspense into these cases produces strange interactions where “the component suddenly suspends after a click, the fallback flashes briefly, then it returns.”

For these cases, continue using useMutation / regular useQuery + manual loading. Suspense is not meant to replace every loading state; it replaces only the category of “an asynchronous value that is a synchronous dependency during rendering.”

The criterion is simple: is this asynchronous value a prerequisite for the component to render normally? Yes → Suspense is appropriate; no, it is the result of a user action → use an imperative approach. Separate these two categories and you will not misuse it.

Summary

  • Suspense is a protocol, not a data-fetching library. It only defines “suspension = throw a promise”; React Query, SWR, and Server Components are implementations of this protocol.
  • Declarative replaces imperative. useSuspenseQuery no longer returns isLoading; components write only the happy path, while the outer boundary handles loading.
  • Streaming SSR is Suspense's greatest value multiplier. Server Components + Suspense make the first render wait only for the slowest “block,” not the entire page. Next.js's loading.tsx is essentially a Suspense boundary.
  • Divide boundary granularity by content blocks. Too fine = visual twitching; too coarse = a blank screen. Layer page framing, main content, and secondary content.
  • Error Boundary goes outside; Suspense goes inside. Together, they cover the full lifecycle of asynchronous components. Do not reverse the order.

Key takeaway: Suspense does not cure “data fetching”; it cures “the glue code between components and asynchronous values that you can never get right.” It makes “waiting for data” declarative again, and turns loading back into the shape of the component tree instead of the hell of a state machine.

Related

Related Content