React Suspense

October 31, 2025

Overview

React Suspense for data fetching lets you declaratively show a fallback UI while React waits for data—without manually juggling isLoading flags across your component tree. In React 18+, Suspense works with streaming SSR, Server Components, and data libraries (Relay, React Query, SWR) to deliver fast, incremental UIs.

In most React apps, loading states sprawl: each component fetches data, tracks isLoading, renders a spinner, and forwards that state down to children. As the tree grows, those flags multiply, and it’s easy to block too much UI—or forget to handle an error path. Suspense flips this: components declare what they need, and a parent boundary declares what to show while any child is waiting. You get centralized loading UX without wiring booleans through half your app.

Scope: this guide focuses on client-side Suspense for data fetching—where to place boundaries and how they interact with SSR/streaming—not on building a full data layer.

What Suspense Guarantees

  • If something below a boundary isn’t ready, React pauses rendering there and shows the fallback.
  • When the data is ready, React reveals the content without tearing the rest of the page.
  • If something fails, the error bubbles to the nearest ErrorBoundary—no scattered try/catch in every hook.

What Suspense Does (and Doesn’t)

  • Does: pause rendering at a boundary until the data needed by its children is ready, then reveal the content.
  • Doesn’t: fetch data by itself. You still use a data source (custom resource, Relay, React Query/SWR with Suspense enabled, RSC).
  • Pairs with: ErrorBoundary, streaming SSR, SuspenseList, useTransition for progressive hydration/UX.

When to Use Suspense (and When Not To)

SituationRecommendationWhySuggested pattern / alternative
Page composed of clear sections (route, sidebar, main, widgets)Use SuspenseProgressively reveal sections instead of blocking the whole pageSection-level boundaries with shared skeletons; consider SuspenseList
Streaming SSR (e.g., Next.js App Router)Use SuspenseBoundaries enable streamed HTML for faster first contentPlace boundaries at route/segment level; pair with ErrorBoundary
80% of loading UI can use reusable skeletonsUse SuspenseConsistent UX with minimal custom codeBuild a small skeleton system (CardSkeleton, ListSkeleton, TextLine, etc.)
First load of a slow list/gridUse SuspenseGood perceived performance with skeletonsBoundary around the list only; default list/grid skeleton
Subsequent refetch of the same viewPrefer minimal SuspenseAvoid flashing back to skeletons“Keep previous data” + small “Updating…” indicator (useTransition or data-lib isFetching)
Need to keep old content visible during data changeMinimize/skipFallbacks would hide useful contextuseTransition to defer state; inline status chip/spinner; optimistic UI
Highly bespoke loaders per componentSkipBoundary/fallback sprawl with little reuseExplicit isLoading per component; consolidate designs or reduce variations
Ultra-interactive controls (inputs, menus, small widgets)SkipFallbacks feel glitchy for tiny interactionsDisable control + inline spinner; defer network on blur/submit
Dashboard with multiple independent panelsUse SuspenseOne slow panel won’t block othersMultiple boundaries; orchestrate with SuspenseList (e.g., revealOrder="forwards")
Centralized error handling desiredUse SuspenseErrors thrown during data read bubble cleanlyWrap boundaries with ErrorBoundary per section
Legacy SSR without streamingMaybe / often skipLess benefit without streaming revealClassic spinners with data-lib states; consider upgrading SSR pipeline
Simple one-off fetch in a small componentMaybe / often skipSuspense may be overkillPlain useEffect + isLoading; upgrade to Suspense if it grows

Examples

Snippets illustrate the pattern; in production, prefer a data library (Relay / React Query / SWR) over a hand-rolled resource, and always pair each boundary with an ErrorBoundary.

Core Pattern: A “Resource” That Suspends

// lib/resource.js
// Minimal "wrap a Promise" pattern that Suspense can pause on
export function createResource(promise) {
  let status = 'pending';
  let result;
  const suspender = promise.then(
    r => { status = 'success'; result = r; },
    e => { status = 'error'; result = e; }
  );
  return {
    read() {
      if (status === 'pending') throw suspender;      // <- makes Suspense show fallback
      if (status === 'error') throw result;           // <- bubbles to ErrorBoundary
      return result;
    }
  };
}
// App.jsx
import React, { Suspense } from 'react';
import { createResource } from './lib/resource';

const usersResource = createResource(fetch('/api/users').then(r => r.json()));

function Users() {
  const users = usersResource.read(); // will suspend until resolved
  return (
    <ul>
      {users.map(u =><li key={u.id}>{u.name}</li>)}
    </ul>
  );
}

function App() {
  return (
    <Suspense fallback={<div>Loading users…</div>}>
      <Users />
    </Suspense>
  );
}
export default App;

In real apps, you’ll likely use a library (Relay, React Query with suspense: true, SWR) rather than hand-rolled resources.

Error Handling with Error Boundaries

// ErrorBoundary.jsx
import React from 'react';

export class ErrorBoundary extends React.Component {
  state = { error: null };
  static getDerivedStateFromError(error) { return { error }; }
  render() {
    if (this.state.error) return <div role="alert">Failed: {this.state.error.message}</div>;
    return this.props.children;
  }
}

// Wrap Suspense with ErrorBoundary
<ErrorBoundary>
  <Suspense fallback={<div>Loading…</div>}>
    <Users />
  </Suspense>
</ErrorBoundary>

Suspense With Data Libraries

React Query (TanStack Query)

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const qc = new QueryClient({ defaultOptions: { queries: { suspense: true } } });

root.render(
  <QueryClientProvider client={qc}>
    <App />
  </QueryClientProvider>
);

// Users.tsx
import { useQuery } from '@tanstack/react-query';

export function Users() {
  const { data } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(r => r.json()) });
  return <ul>{data.map((u: any) =><li key={u.id}>{u.name}</li>)}</ul>;
}

// App.tsx
import { Suspense } from 'react';
export default function App() {
  return (
    <Suspense fallback={<div>Loading users…</div>}>
      <Users />
    </Suspense>
  );
}

SWR (with Suspense)

// Profile.jsx
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(r => r.json());

export function Profile() {
  const { data } = useSWR('/api/me', fetcher, { suspense: true });
  return <div>{data.name}</div>;
}
Relay (Built for Suspense)

Relay’s hooks (e.g., usePreloadedQuery) suspend by default when data isn’t ready, and integrate tightly with streaming SSR.

Streaming SSR & Route-Level Boundaries

With React 18’s streaming SSR (and frameworks like Next.js App Router), you can place multiple Suspense boundaries in a page. The server streams HTML as each boundary resolves, so users see content progressively.

// Page.tsx (Next.js App Router)
import { Suspense } from 'react';
import { Products } from './Products';
import { Sidebar } from './Sidebar';

export default function Page() {
  return (
    <>
      <Suspense fallback={<div>Loading sidebar…</div>}>
        <Sidebar />
      </Suspense>
      <Suspense fallback={<div>Loading products…</div>}>
        <Products />
      </Suspense>
    </>
  );
}

Coordinating Multiple Boundaries: SuspenseList

import { Suspense, SuspenseList } from 'react';

<SuspenseList revealOrder="forwards" tail="collapsed">
  <Suspense fallback={<SkeletonA />}><PanelA /></Suspense>
  <Suspense fallback={<SkeletonB />}><PanelB /></Suspense>
  <Suspense fallback={<SkeletonC />}><PanelC /></Suspense>
</SuspenseList>
  • revealOrder="forwards": show in order as each becomes ready.
  • tail="collapsed": hide subsequent fallbacks to reduce layout shifts.

Transitions for Smoother UI

Use useTransition to keep the current UI visible while kicking off a data fetch that will suspend.

import React, { Suspense, useState, useTransition } from 'react';

function SearchResults({ query }) {
  const resource = getSearchResource(query); // returns a suspending resource
  const results = resource.read();
  return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}

export default function Search() {
  const [query, setQuery] = useState('react');
  const [pending, startTransition] = useTransition();

  function onChange(e) {
    const q = e.target.value;
    startTransition(() => setQuery(q)); // suspends without janking the UI
  }

  return (
    <>
      <input onChange={onChange} placeholder="Search…" />
      {pending && <span>Updating…</span>}
      <Suspense fallback={<div>Loading results…</div>}>
        <SearchResults query={query} />
      </Suspense>
    </>
  );
}

Approach Comparison: Suspense, Data Libraries, and useEffect

ApproachBest ForProsCons
Suspense + RelayGraphQL apps needing SSR streaming & fine-grained boundariesTight Suspense integration; preloading; great perfRequires Relay runtime & GraphQL schema
Suspense + React Query / SWRREST or GraphQL with flexible cachingToggle suspense: true; caching, retries, revalidationMust coordinate error boundaries; hydration nuances
Server Components (RSC)Static/read-mostly data & streaming HTML0 KB JS for server-only parts; built-in streamingInteractivity requires client components
useEffect + local stateSmall components; simple fetchesExplicit control; library-freeManual isLoading/error plumbing; harder SSR/streaming

Fallback Strategy: UX Trade-offs

Fallback PatternProsConsNotes
Global page spinnerSimple; minimal codeBig perceived delay; no content until all readyUse only for full-page fetches
Section skeletonsFaster perceived load; reduces layout shiftMore components to maintainPlace boundaries around logical sections
Progressive reveal (SuspenseList)Guided reveal order; stable layoutMore orchestrationGreat for dashboards & feeds

Common Pitfalls (and Fixes)

PitfallWhy it happensFix
Throwing non-Promise valuesResource throws something Suspense can’t handleThrow the pending Promise to suspend; throw Error for failures
No ErrorBoundaryErrors bubble and crash the treeWrap Suspense with an ErrorBoundary
Global boundary onlyEntire page blocked by one slow requestUse multiple boundaries; skeletons for sections
Mismatch in SSR hydrationData on server & client out of syncUse the same data cache on both; dehydrate/rehydrate (React Query/Relay)
Refetch loops with transitionsState updates retrigger fetch repeatedlyMemoize keys; debounce inputs; guard effects

Testing Suspense UIs

  • Unit: Use React Testing Library’s await screen.findByText(...) to wait for reveal.
  • Error paths: Simulate rejected promises; assert ErrorBoundary content.
  • Loading UX: Assert fallbacks show first, then content appears.
  • Integration (SSR): Verify streamed chunks (framework-dependent) and hydration without warnings.

Alternatives & When Not to Use Suspense

  • Simple components / fetch-on-click: Plain useEffect may be clearer.
  • Legacy SSR stacks without streaming: Suspense benefits shrink; consider library spinners.
  • Non-React data consumers: If many clients share an API, keep a standard API layer.

Conclusion

Suspense brings declarative loading states and progressive rendering to React’s core model. Use it with multiple boundaries, ErrorBoundary components, and transitions for smooth experiences; pair with Relay, React Query, SWR, or Server Components to fetch efficiently. Start small—wrap one slow section with a Suspense boundary—and grow from there.