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)

    Situation Recommendation Why Suggested pattern / alternative
    Page composed of clear sections (route, sidebar, main, widgets) Use Suspense Progressively reveal sections instead of blocking the whole page Section-level boundaries with shared skeletons; consider SuspenseList
    Streaming SSR (e.g., Next.js App Router) Use Suspense Boundaries enable streamed HTML for faster first content Place boundaries at route/segment level; pair with ErrorBoundary
    80% of loading UI can use reusable skeletons Use Suspense Consistent UX with minimal custom code Build a small skeleton system (CardSkeleton, ListSkeleton, TextLine, etc.)
    First load of a slow list/grid Use Suspense Good perceived performance with skeletons Boundary around the list only; default list/grid skeleton
    Subsequent refetch of the same view Prefer minimal Suspense Avoid flashing back to skeletons “Keep previous data” + small “Updating…” indicator (useTransition or data-lib isFetching)
    Need to keep old content visible during data change Minimize/skip Fallbacks would hide useful context useTransition to defer state; inline status chip/spinner; optimistic UI
    Highly bespoke loaders per component Skip Boundary/fallback sprawl with little reuse Explicit isLoading per component; consolidate designs or reduce variations
    Ultra-interactive controls (inputs, menus, small widgets) Skip Fallbacks feel glitchy for tiny interactions Disable control + inline spinner; defer network on blur/submit
    Dashboard with multiple independent panels Use Suspense One slow panel won’t block others Multiple boundaries; orchestrate with SuspenseList (e.g., revealOrder="forwards")
    Centralized error handling desired Use Suspense Errors thrown during data read bubble cleanly Wrap boundaries with ErrorBoundary per section
    Legacy SSR without streaming Maybe / often skip Less benefit without streaming reveal Classic spinners with data-lib states; consider upgrading SSR pipeline
    Simple one-off fetch in a small component Maybe / often skip Suspense may be overkill Plain 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

    Approach Best For Pros Cons
    Suspense + Relay GraphQL apps needing SSR streaming & fine-grained boundaries Tight Suspense integration; preloading; great perf Requires Relay runtime & GraphQL schema
    Suspense + React Query / SWR REST or GraphQL with flexible caching Toggle suspense: true; caching, retries, revalidation Must coordinate error boundaries; hydration nuances
    Server Components (RSC) Static/read-mostly data & streaming HTML 0 KB JS for server-only parts; built-in streaming Interactivity requires client components
    useEffect + local state Small components; simple fetches Explicit control; library-free Manual isLoading/error plumbing; harder SSR/streaming

    Fallback Strategy: UX Trade-offs

    Fallback Pattern Pros Cons Notes
    Global page spinner Simple; minimal code Big perceived delay; no content until all ready Use only for full-page fetches
    Section skeletons Faster perceived load; reduces layout shift More components to maintain Place boundaries around logical sections
    Progressive reveal (SuspenseList) Guided reveal order; stable layout More orchestration Great for dashboards & feeds

    Common Pitfalls (and Fixes)

    Pitfall Why it happens Fix
    Throwing non-Promise values Resource throws something Suspense can’t handle Throw the pending Promise to suspend; throw Error for failures
    No ErrorBoundary Errors bubble and crash the tree Wrap Suspense with an ErrorBoundary
    Global boundary only Entire page blocked by one slow request Use multiple boundaries; skeletons for sections
    Mismatch in SSR hydration Data on server & client out of sync Use the same data cache on both; dehydrate/rehydrate (React Query/Relay)
    Refetch loops with transitions State updates retrigger fetch repeatedly Memoize 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.