Topics
v5.0.0
On this page · sections

Suspense & Async React

Suspense lets components declare loading state declaratively — when a child throws a promise during render, React shows a fallback until it resolves. Pair with Error Boundaries for rejections and transitions for keeping stale UI during pending updates.

MechanismHandlesRequires boundary
<Suspense>Pending async renderYes — nearest ancestor
use(promise)Read promise in renderYes — for promise path
use(context)Read context conditionallyNo — does not suspend
Error BoundaryThrown errors / rejectionsYes — outside Suspense
startTransitionKeep stale UI during pendingNo — scheduler feature

What Suspense Does

Definition

When a component suspends during render, React walks up to the nearest <Suspense> ancestor, shows its fallback, and holds incomplete work. When the promise resolves, React retries render and commits atomically.

'use client'; import { Suspense, lazy } from 'react'; const HeavyChart = lazy(() => import('./HeavyChart')); function Dashboard() { return ( <Suspense fallback={<p>Loading chart…</p>}> <HeavyChart /> </Suspense> ); }

React.lazy throws a load promise on first render. The boundary shows fallback until the chunk resolves. Suspending happens during render, not in useEffect. Effects run after paint and cannot signal pending state to a boundary.

Interview: Suspense handles pending. Error Boundary handles rejection. They are separate paths.

Nested Boundaries

Definition

Each Suspense boundary resolves independently — sibling boundaries load in parallel:

function Page() { return ( <div className="grid"> <Suspense fallback={<SidebarSkeleton />}> <SidebarData /> </Suspense> <Suspense fallback={<FeedSkeleton />}> <FeedData /> </Suspense> </div> ); }

Static layout outside both boundaries paints immediately. One root boundary blanks the whole page when any child suspends.

Waterfall risk

Serial await chains in render order where child fetch starts only after parent resolves. Fix by hoisting parallel promise creation to a common ancestor or using nested boundaries at independent data regions.

In Next.js App Router, loading.tsx is a file-system Suspense boundary per route segment.

Interview: Place boundaries close to data, not at the app root — unless you want a full-page loader.

The use() Hook

Definition

React 19’s use() reads a Promise or Context during render:

'use client'; import { createContext, Suspense, use } from 'react'; const ThemeContext = createContext<'light' | 'dark'>('light'); function UserProfile({ userPromise }: { userPromise: Promise<User> }) { const user = use(userPromise); return <h1>{user.name}</h1>; } function UserPage({ id }: { id: string }) { const userPromise = fetchUser(id); return ( <Suspense fallback={<p>Loading user…</p>}> <UserProfile userPromise={userPromise} /> </Suspense> ); }

Promise path: suspends until fulfilled; must be under Suspense. Rejected promise propagates to Error Boundary.

Context path: works like useContext but can be called after conditional returns.

Interview: use(context) can appear after an early return. useContext cannot — hooks must run unconditionally.

Cache + Read Pattern

Definition

Data libraries and React cache() follow a contract: first read starts fetch and throws a promise; retry read returns cached data.

import { Suspense, cache } from 'react'; const getProduct = cache(async (id: string) => { const res = await fetch(`https://api.example.com/products/${id}`, { next: { revalidate: 60 }, }); return res.json(); }); export default function ProductPage({ params }: { params: { id: string } }) { const productPromise = getProduct(params.id); return ( <Suspense fallback={<ProductSkeleton />}> <ProductDetails productPromise={productPromise} /> </Suspense> ); } async function ProductDetails({ productPromise }: { productPromise: Promise<Product> }) { const product = await productPromise; return ( <div> <h1>{product.name}</h1> <p>${product.price}</p> </div> ); }

Anti-pattern: useEffect fetch + Suspense — effects do not suspend. Anti-pattern: new promise every render — creates a suspend loop. Cache by stable key.

Interview: Hoist promises high in the tree. Consume low with use() or await in Server Components.

Error Boundaries

Definition

Pair Error Boundary outside Suspense for complete async UX:

ErrorBoundary > Suspense > DataComponent Suspense → pending Error Boundary → rejection
'use client'; import { Component, type ReactNode, Suspense } from 'react'; type ErrorBoundaryProps = { fallback: ReactNode; children: ReactNode }; type ErrorBoundaryState = { hasError: boolean }; class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { state = { hasError: false }; static getDerivedStateFromError() { return { hasError: true }; } render() { if (this.state.hasError) return this.props.fallback; return this.props.children; } } function App() { return ( <ErrorBoundary fallback={<p>Something went wrong.</p>}> <Suspense fallback={<p>Loading…</p>}> <UserProfile userPromise={fetchUser('1')} /> </Suspense> </ErrorBoundary> ); }

try/catch around use() in the same component does not work — the boundary must be above. Next.js error.tsx is a file-system error boundary per route segment — pairs with loading.tsx Suspense sibling.

Interview: Rejected fetch → Error Boundary, not Suspense. error.tsx catches render errors in that segment.

Transitions + Suspense

Definition

In React 18+, an update wrapped in startTransition does not replace already-visible content with a fallback. It keeps the current UI on screen while the new tree prepares.

'use client'; import { Suspense, useState, useTransition } from 'react'; function App() { const [query, setQuery] = useState(''); const [filter, setFilter] = useState(''); const [isPending, startTransition] = useTransition(); return ( <> <input value={query} onChange={(e) => { setQuery(e.target.value); startTransition(() => setFilter(e.target.value)); }} aria-busy={isPending} /> <Suspense fallback={<p>Loading results…</p>}> <SearchResults query={filter} /> </Suspense> </> ); }

First load shows fallback. Subsequent updates with a transition keep stale content visible.

Interview: Transitions + Suspense = keep stale content during pending update. Suspense alone = fallback on first paint.

RSC and Streaming

Definition

Server Component → await fetch directly; HTML streams through boundaries Pass promise to client island → Client child uses use(promise) under Suspense cache() on server → Dedupe per request; one fetch shared across components

Measure TTFB, shell paint, and content reveal separately — they diagnose different bottlenecks.

Interview: Server Components await directly. Client islands receive pre-started promises and call use(promise).

Senior Interview Summary

What to say

Suspense shows a fallback when a child throws a promise during render. use(promise) suspends until resolved; rejection goes to Error Boundary. Place boundaries near data, cache promises by stable key, and pair ErrorBoundary > Suspense for complete async UX.

Best mental model

Suspend → "I'm not ready — hold this subtree." Fallback → "Show this until I'm ready." use() → "Read this promise or context in render." Boundary → "How far up does the loading UI propagate?"

Practical rule

Lazy import / async data → Suspense boundary near consumer Cached promise by id → stable suspension First load → fallback; subsequent with transition → stale UI Rejected fetch → Error Boundary, not Suspense Parallel regions → sibling boundaries, not one root

Last updated on