DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

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

Suspense has been in React since 16.6, but data fetching with Suspense was experimental until React 18/19. Now it's a first-class async UI primitive, and the new use() hook gives you a direct way to integrate it without a library.

What Suspense Actually Does

Suspense doesn't know anything about fetching. When a component throws a Promise during render, React catches it, renders the nearest <Suspense> boundary's fallback, and retries when the Promise resolves.

// 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
}
Enter fullscreen mode Exit fullscreen mode

The use() Hook

React 19's use() unwraps a Promise (or Context) inline during render, integrating with Suspense automatically:

import { use, Suspense } from 'react'

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise) // suspends until resolved
  return <div><h2>{user.name}</h2><p>{user.email}</p></div>
}

function Page() {
  const userPromise = fetchUser('123') // created once, passed down

  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

Critical: Create the Promise outside the component. If you create it inside, you get a new Promise on every render — infinite suspense loop:

// ❌ New Promise every render — infinite loop
function UserProfile({ id }: { id: string }) {
  const user = use(fetchUser(id))
}

// ✅ Stable Promise reference passed as prop
function Page({ id }: { id: string }) {
  const userPromise = useMemo(() => fetchUser(id), [id])
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

use() with Context

use() also works as a context reader — unlike useContext, it can be called inside conditions:

function Button({ disabled }: { disabled?: boolean }) {
  if (disabled) return <button disabled>...</button>

  const theme = use(ThemeContext) // ✅ conditional call works
  return <button className={theme.buttonClass}>...</button>
}
Enter fullscreen mode Exit fullscreen mode

Error Boundaries

When a Promise rejects, you need an Error Boundary (Suspense only handles pending state):

npm install react-error-boundary
Enter fullscreen mode Exit fullscreen mode
import { ErrorBoundary } from 'react-error-boundary'

function FallbackUI({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div>
      <p>Failed to load: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  )
}

// ErrorBoundary MUST be outside Suspense
<ErrorBoundary FallbackComponent={FallbackUI}>
  <Suspense fallback={<Loading />}>
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

Nested Suspense: Progressive Loading

Instead of one giant loading state, nest boundaries so each section loads independently:

export default function DashboardPage() {
  const statsPromise = fetchStats()          // fast
  const activityPromise = fetchActivity()    // medium
  const recsPromise = fetchRecommendations() // slow

  return (
    <main>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection statsPromise={statsPromise} />
      </Suspense>

      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed activityPromise={activityPromise} />
      </Suspense>

      <Suspense fallback={<RecsSkeleton />}>
        <Recommendations recsPromise={recsPromise} />
      </Suspense>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

Users see content progressively as each section resolves — no waiting for the slowest section.

Suspense in Next.js Server Components

Server Components can be async — they await data directly. Suspense coordinates streaming:

// Server Component — no 'use client'
async function StatsSection() {
  const stats = await fetchStats()
  return <StatsGrid stats={stats} />
}

export default function DashboardPage() {
  return (
    <main>
      {/* HTML streams to the client as each section completes */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection />
      </Suspense>
    </main>
  )
}
Enter fullscreen mode Exit fullscreen mode

No client-side JavaScript needed, no useEffect, no re-fetching on hydration.

Pass Promises from Server to Client

Kick off fetching on the server, pass the Promise to a Client Component:

// Server Component — starts fetch immediately, doesn't await
export default async function ProductPage({ params }: { params: { id: string } }) {
  const reviewsPromise = fetchReviews(params.id) // no await

  return (
    <div>
      <ProductInfo id={params.id} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsSection reviewsPromise={reviewsPromise} />
      </Suspense>
    </div>
  )
}

// Client Component
'use client'
function ReviewsSection({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
  const reviews = use(reviewsPromise)
  return <ReviewsList reviews={reviews} />
}
Enter fullscreen mode Exit fullscreen mode

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

useTransition + Suspense: Avoid Fallback Flicker

When navigating or updating state, useTransition keeps the old content visible instead of showing the skeleton:

'use client'

function ProductList() {
  const [isPending, startTransition] = useTransition()
  const [category, setCategory] = useState('all')

  function handleChange(newCategory: string) {
    startTransition(() => setCategory(newCategory))
  }

  return (
    <div>
      <CategoryFilter onChange={handleChange} disabled={isPending} />
      {/* No skeleton flash — old content stays visible while new loads */}
      <Suspense fallback={<ProductsSkeleton />}>
        <Products category={category} />
      </Suspense>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Without startTransition: change → skeleton flash → new content.
With startTransition: change → old content stays (pending) → new content appears.

Common Mistakes

// ❌ ErrorBoundary inside Suspense — rejections bypass it
<Suspense fallback={<Skeleton />}>
  <ErrorBoundary fallback={<Error />}>  {/* wrong order */}

// ✅ ErrorBoundary outside Suspense
<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Skeleton />}>

// ❌ No Suspense boundary around use()
function Page() {
  return <UserProfile userPromise={promise} />  // throws: no fallback specified

// ✅
function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userPromise={promise} />
    </Suspense>
  )
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference

// use() for Promises
const data = use(dataPromise)

// use() for Context (works in conditions)
const theme = use(ThemeContext)

// Standard pattern
<Suspense fallback={<Loading />}>
  <AsyncComponent />
</Suspense>

// With error handling
<ErrorBoundary FallbackComponent={ErrorFallback}>
  <Suspense fallback={<Loading />}>
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>

// Avoid skeleton flash on navigation
startTransition(() => setState(newValue))

// Server Component
async function ServerComp() {
  const data = await fetchData()
  return <div>{data.title}</div>
}
Enter fullscreen mode Exit fullscreen mode

Suspense and use() 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 plugs into this mechanism — the coordination logic stays in React.


Full article at stacknotice.com/blog/react-suspense-use-hook-2026

Top comments (0)