ReactSuspense for Loading States

Suspense for Loading States

Before Suspense, every component that fetched data needed to manage its own isLoading / error / data state. The result was boilerplate scattered across every leaf component and inconsistent loading UIs. Suspense inverts this: a component simply reads data as if it were already available, and React handles the loading state at a boundary you choose. The fallback UI lives exactly once, at the right level of the tree.

How Suspense Works

<Suspense> renders its fallback while any child component is "suspended". A component suspends by throwing a Promise during render — React catches it, renders the fallback, and re-tries the child once the Promise resolves. You never call throw yourself; the library (React.lazy, React Query, SWR, or RSC) does it on your behalf.

JSX
import { Suspense } from 'react'

// While <UserProfile> is suspended (loading data), React renders the fallback.
// Once data is ready, React swaps in <UserProfile> automatically.
function App() {
  return (
    <Suspense fallback={<p>Loading profile…</p>}>
      <UserProfile userId={1} />
    </Suspense>
  )
}
With React.lazy

React.lazy is the most widely used integration today. The lazy component suspends until its JavaScript chunk has been downloaded and parsed:

JSX
import { lazy, Suspense } from 'react'

const HeavyEditor = lazy(() => import('./HeavyEditor'))

function WritingPage() {
  return (
    <div>
      <h1>New Post</h1>

      {/* Suspense fallback displays while HeavyEditor.js loads */}
      <Suspense fallback={<p>Loading editor…</p>}>
        <HeavyEditor />
      </Suspense>
    </div>
  )
}
With Data Fetching — React Query Suspense Mode

TanStack Query (React Query) v5 supports Suspense natively via useSuspenseQuery. The component suspenses while the query is loading — no isLoading check needed:

JSX
import { Suspense } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'

// This component NEVER renders in a loading state —
// it only renders when data is available
function UserCard({ userId }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  })

  // 'user' is always defined here — no null checks needed
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}

// The loading state is handled one level up, in the boundary
function ProfilePage({ userId }) {
  return (
    <Suspense fallback={<UserCardSkeleton />}>
      <UserCard userId={userId} />
    </Suspense>
  )
}
Note
useSuspenseQuery guarantees that data is defined when the component renders. This eliminates the entire category of "forgot to handle loading state" bugs.
Nesting Suspense Boundaries

You can nest <Suspense> boundaries to create granular loading states. An outer boundary shows a coarse fallback; inner boundaries show fine-grained spinners for specific sections. Each boundary catches only the Promises thrown by its direct subtree:

JSX
import { Suspense } from 'react'

function Dashboard() {
  return (
    // Outer boundary: entire dashboard fallback
    <Suspense fallback={<DashboardSkeleton />}>
      <DashboardHeader />

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
        {/* Each panel has its own boundary — they load independently */}
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<ChartSkeleton />}>
          <UserGrowthChart />
        </Suspense>
      </div>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrdersTable />
      </Suspense>
    </Suspense>
  )
}

// RevenueChart and UserGrowthChart load in parallel.
// If RevenueChart is slow, UserGrowthChart still appears as soon as it's ready.
Tip
Granular Suspense boundaries make your app feel faster. Instead of waiting for the slowest data source before showing anything, each section appears the moment its own data is ready.
Suspense vs Manual isLoading State

To understand what Suspense saves you, compare the same component written both ways:

JSX
// ─── Without Suspense ────────────────────────────────────────────
function PostList() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })

  if (isLoading) return <p>Loading…</p>          // ← scattered loading state
  if (isError)   return <p>Error loading posts</p>  // ← scattered error state

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

// ─── With Suspense ────────────────────────────────────────────────
function PostList() {
  const { data } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  })

  // data is always defined — no guards needed
  return (
    <ul>
      {data.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  )
}

// Loading and error states live in ONE place outside:
<ErrorBoundary fallback={<p>Error loading posts</p>}>
  <Suspense fallback={<p>Loading…</p>}>
    <PostList />
  </Suspense>
</ErrorBoundary>
Error Handling with ErrorBoundary

Suspense handles the loading case; ErrorBoundary handles the failure case. They complement each other — always pair them when fetching data:

JSX
import { Suspense, Component } from 'react'

class ErrorBoundary extends Component {
  state = { error: null }

  static getDerivedStateFromError(error) {
    return { error }
  }

  render() {
    if (this.state.error) {
      return (
        <div>
          <p>Something went wrong: {this.state.error.message}</p>
          <button onClick={() => this.setState({ error: null })}>Retry</button>
        </div>
      )
    }
    return this.props.children
  }
}

function SafeProfile({ userId }) {
  return (
    <ErrorBoundary>           {/* catches fetch errors */}
      <Suspense fallback={<Spinner />}>  {/* shows while loading */}
        <UserProfile userId={userId} />
      </Suspense>
    </ErrorBoundary>
  )
}
Suspense with React Server Components (RSC)

In Next.js App Router, Server Components can await data directly. React streams the response to the client and fills in each Suspense boundary as its data becomes ready — without any client-side JavaScript for the fetch itself:

TSX
// app/dashboard/page.tsx — Next.js App Router
import { Suspense } from 'react'

// async Server Component — fetches data on the server
async function RevenueStats() {
  const stats = await getRevenueStats()   // direct DB/API call, no useEffect
  return <StatsCard data={stats} />
}

async function RecentOrders() {
  const orders = await getRecentOrders()
  return <OrdersTable data={orders} />
}

// This page streams: header appears immediately,
// then each section fills in as its server fetch completes
export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>

      <Suspense fallback={<StatsSkeleton />}>
        <RevenueStats />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </main>
  )
}
  • <Suspense fallback={...}> renders the fallback while children are suspended.

  • Works with React.lazy (code splitting) today, and data fetching via React Query/SWR/RSC.

  • Nest boundaries for granular, independent loading states — each section loads as soon as its own data arrives.

  • Always pair with ErrorBoundary — Suspense handles loading, ErrorBoundary handles errors.

  • In Next.js App Router, streaming + Suspense means the shell HTML arrives immediately and sections fill in progressively.