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.