React
|stacknotice.com
11 min left|
0%
|2,200 words
React

React Suspense & the use() Hook: Complete Guide (2026)

How React Suspense actually works, the use() hook for unwrapping promises and context, error boundaries, nested Suspense, and how it all fits with Next.js Server Components.

C
Carlos Oliva
Software Developer
June 29, 202611 min read
Share:
React Suspense & the use() Hook: Complete Guide (2026)

Suspense has been in React since version 16.6, but for most of that time it only did one thing well: code splitting with React.lazy. Data fetching with Suspense required opinionated libraries and was technically experimental. In React 18 and 19, that changed — Suspense is now a first-class coordination primitive for async UI, and the new use() hook gives you a direct way to integrate it without a library.

This guide covers what Suspense actually does, how use() works, error boundaries, nested Suspense patterns, and how all of this fits together with Next.js Server Components.

What Suspense Actually Does

Suspense doesn't know anything about fetching. It's a coordination primitive — it lets a child component signal "I'm not ready yet" and temporarily renders a fallback in its place.

The signal is a thrown Promise. When a component throws a Promise during render, React catches it, renders the nearest <Suspense> boundary's fallback, and retries the component when the Promise resolves. This is the mechanism — libraries and the use() hook both work by throwing Promises.

// Conceptually, what libraries do:
function fetchUser(id: string) {
  if (cache.has(id)) return cache.get(id)
 
  const promise = fetch(`/api/users/${id}`).then(r => r.json())
  cache.set(id, promise)
  throw promise  // ← Suspense catches this
}
 
function UserCard({ id }: { id: string }) {
  const user = fetchUser(id) // may throw a Promise
  return <div>{user.name}</div>
}
 
// Suspense catches the thrown Promise and shows fallback
<Suspense fallback={<Skeleton />}>
  <UserCard id="123" />
</Suspense>

This is why libraries that "support Suspense" work with it: they throw a pending Promise when data isn't ready yet, and resolve it when it is.

The use() Hook

React 19 introduced use() — a hook that unwraps a Promise (or Context) inline during render. Unlike useEffect or useState, use() can be called inside loops and conditionals, and it integrates with Suspense automatically.

import { use, Suspense } from 'react'
 
// A component that uses use() to unwrap a Promise
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise) // suspends until the promise resolves
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}
 
// Usage — the promise is created outside the component
function Page() {
  const userPromise = fetchUser('123') // created once, passed down
 
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

Critical: where you create the Promise matters. If you create it inside the component, you get a new Promise on every render, which means an infinite suspense loop:

// ❌ New Promise every render — infinite loop
function UserProfile({ id }: { id: string }) {
  const user = use(fetchUser(id)) // fetchUser() called on every render
}
 
// ✅ Promise created once, passed as prop
function Page({ id }: { id: string }) {
  const userPromise = useMemo(() => fetchUser(id), [id]) // stable reference
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

Or create the Promise outside React entirely (in a module-level cache or a server function).

use() with Context

use() also works as a context reader — identical to useContext() but callable inside conditions:

import { use } from 'react'
import { ThemeContext } from '@/contexts/theme'
 
function Button({ disabled }: { disabled?: boolean }) {
  // ✅ Can call use() inside a condition (unlike useContext)
  if (disabled) {
    return <button disabled>...</button>
  }
 
  const theme = use(ThemeContext)
  return <button className={theme.buttonClass}>...</button>
}

Error Boundaries: Handling Failures

When a suspended component's Promise rejects (network error, server error), Suspense doesn't help — you need an Error Boundary. Error boundaries catch thrown errors during render, including rejected promises unwrapped by use().

React doesn't ship a class component error boundary — you write your own or use react-error-boundary:

npm install react-error-boundary
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
 
function UserSection({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise)
  return <UserCard user={user} />
}
 
function FallbackUI({ error, resetErrorBoundary }: { error: Error, resetErrorBoundary: () => void }) {
  return (
    <div className="rounded-lg border border-destructive p-4">
      <p className="text-destructive">Failed to load user: {error.message}</p>
      <button onClick={resetErrorBoundary} className="mt-2 text-sm underline">
        Try again
      </button>
    </div>
  )
}
 
// Wrap with both — ErrorBoundary outside, Suspense inside
export function UserSectionWithFallbacks({ userPromise }: { userPromise: Promise<User> }) {
  return (
    <ErrorBoundary FallbackComponent={FallbackUI}>
      <Suspense fallback={<UserSkeleton />}>
        <UserSection userPromise={userPromise} />
      </Suspense>
    </ErrorBoundary>
  )
}

The error boundary must be outside the Suspense boundary. If you put Suspense outside ErrorBoundary, rejections bypass the error boundary.

Reset after error

react-error-boundary provides resetErrorBoundary to clear the error state (useful when the user clicks "retry"):

function FallbackUI({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div>
      <p>{error.message}</p>
      <button
        onClick={resetErrorBoundary}
        // Optional: also pass resetKeys to auto-reset when these values change
      >
        Retry
      </button>
    </div>
  )
}
 
// resetKeys: auto-reset boundary when userId changes
<ErrorBoundary
  FallbackComponent={FallbackUI}
  resetKeys={[userId]}
>

Nested Suspense: Granular Loading States

Instead of one giant loading state for the whole page, nest Suspense boundaries to give each section its own loading state. Sections that load faster appear immediately; slower sections show their own skeletons independently.

export default function DashboardPage() {
  const statsPromise = fetchStats()
  const activityPromise = fetchActivity()
  const recommendationsPromise = fetchRecommendations() // slow
 
  return (
    <main className="grid gap-6">
      {/* Stats load fast — show immediately */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection statsPromise={statsPromise} />
      </Suspense>
 
      {/* Activity is medium speed */}
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed activityPromise={activityPromise} />
      </Suspense>
 
      {/* Recommendations are slow — show their own skeleton independently */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations recommendationsPromise={recommendationsPromise} />
      </Suspense>
    </main>
  )
}

Without nested Suspense, you'd have to wait for the slowest section before showing anything. With nested boundaries, users see content progressively as each section resolves.

Tip from the PPR article: This is exactly the model Next.js Partial Prerendering uses — the static shell (the page structure) is served immediately, and each <Suspense> boundary is a slot that streams in when its data is ready. See the Next.js PPR guide for how this works in the Next.js App Router.

Suspense in Next.js Server Components

In the Next.js App Router, Server Components can be async — they await data directly without any client-side fetching. Suspense coordinates streaming:

// app/dashboard/page.tsx — Server Component (no 'use client')
 
async function StatsSection() {
  const stats = await fetchStats() // awaited on the server
  return <StatsGrid stats={stats} />
}
 
async function ActivityFeed() {
  await new Promise(r => setTimeout(r, 2000)) // simulate slow query
  const activity = await fetchActivity()
  return <ActivityList items={activity} />
}
 
export default function DashboardPage() {
  return (
    <main>
      {/* Server-side Suspense: HTML streams to the client as each section completes */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection />
      </Suspense>
 
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed />
      </Suspense>
    </main>
  )
}

The HTML is streamed to the browser. <StatsSection /> arrives first (fast query), then <ActivityFeed /> streams in 2 seconds later — no client-side JavaScript needed, no useEffect, no re-fetching on hydration.

Server Components vs Client Components with use()

Server Component (async)Client Component (use())
Where data is fetchedServerServer or client
Syntaxconst data = await fetch(...)const data = use(promise)
StreamingYes, with <Suspense>Yes, with <Suspense>
Access to browser APIsNoYes
Access to secretsYesNo
Error handlingError boundary or try/catchError boundary

Use Server Components + async/await as the default in Next.js. Reach for use() in Client Components when you need to pass a Promise from a Server Component down to a client component for progressive enhancement.

Passing Promises from Server to Client

A useful pattern: kick off data fetching on the server, pass the Promise to a Client Component, let use() unwrap it. The fetch starts immediately on the server, and the client-side component shows a skeleton until it resolves:

// Server Component — starts the fetch immediately
export default async function ProductPage({ params }: { params: { id: string } }) {
  // Don't await — pass the Promise directly
  const reviewsPromise = fetchReviews(params.id)
 
  return (
    <div>
      <ProductInfo id={params.id} />  {/* Server-rendered immediately */}
 
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsSection reviewsPromise={reviewsPromise} />
      </Suspense>
    </div>
  )
}
 
// Client Component — unwraps the Promise
'use client'
import { use } from 'react'
 
function ReviewsSection({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
  const reviews = use(reviewsPromise) // suspends until resolved
  return <ReviewsList reviews={reviews} />
}

The fetch starts on the server before the client even hydrates. By the time hydration happens, the Promise may already be resolved.

useTransition + Suspense: Avoiding Fallback Flicker

When you navigate or update something and Suspense kicks in, you get a flash: the old content disappears and the skeleton appears while new content loads. useTransition lets you keep the old content visible during the transition:

'use client'
 
import { useTransition, Suspense } from 'react'
 
function ProductList() {
  const [isPending, startTransition] = useTransition()
  const [category, setCategory] = useState('all')
 
  function handleCategoryChange(newCategory: string) {
    startTransition(() => {
      setCategory(newCategory)
    })
  }
 
  return (
    <div>
      <CategoryFilter
        value={category}
        onChange={handleCategoryChange}
        disabled={isPending}
      />
 
      {/* No skeleton flash — old products stay visible while new ones load */}
      <Suspense fallback={<ProductsSkeleton />}>
        <Products category={category} />
      </Suspense>
    </div>
  )
}

Inside a startTransition, React won't show the Suspense fallback for the new content — it keeps the current UI visible and marks it as "stale" (you can show a pending indicator via isPending) until the new content is ready.

Without startTransition: category changes → skeleton flicker → new products. With startTransition: category changes → old products stay (slightly faded) → new products appear.

See the React 19 Transitions guide for the full useTransition and useDeferredValue breakdown.

Common Mistakes

Creating Promises inside the component body:

// ❌ New Promise on every render
function ProductCard({ id }: { id: string }) {
  const data = use(fetch(`/api/products/${id}`).then(r => r.json()))
}
 
// ✅ Stable Promise reference
function ProductCard({ dataPromise }: { dataPromise: Promise<Product> }) {
  const data = use(dataPromise)
}

Putting ErrorBoundary inside Suspense:

// ❌ Rejections won't reach the error boundary
<Suspense fallback={<Skeleton />}>
  <ErrorBoundary fallback={<Error />}>
    <Component />
  </ErrorBoundary>
</Suspense>
 
// ✅ ErrorBoundary outside Suspense
<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Skeleton />}>
    <Component />
  </Suspense>
</ErrorBoundary>

No Suspense boundary around a component that uses use():

// ❌ React throws: "A component suspended while rendering, but no fallback UI was specified"
function Page() {
  return <UserProfile userPromise={promise} />
}
 
// ✅ Always wrap with Suspense
function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={promise} />
    </Suspense>
  )
}

Quick Reference

// use() for Promises — suspends until resolved
const data = use(dataPromise)
 
// use() for Context — same as useContext, but works in conditions
const theme = use(ThemeContext)
 
// Standard Suspense wrapper
<Suspense fallback={<Skeleton />}>
  <Component />
</Suspense>
 
// Full pattern with error handling
<ErrorBoundary FallbackComponent={ErrorFallback}>
  <Suspense fallback={<Loading />}>
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>
 
// Avoid fallback flicker on updates
startTransition(() => setState(newValue))
 
// Server Component — async/await directly
async function ServerComponent() {
  const data = await fetchData()
  return <div>{data.title}</div>
}
 
// Pass Promise from Server to Client Component
// Server: const promise = fetchData() — don't await
// Client: const data = use(promise)

Suspense and use() work best when you think of them as coordination primitives rather than data fetching tools. They answer one question: "is this part of the UI ready to render?" If not, show the fallback. When it is, swap in the real content. The data layer — whether that's React Query, SWR, server actions, or raw promises — plugs into this mechanism, but the coordination logic stays in React.

#react#react19#suspense#nextjs#performance
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.