In one minute
A component function must finish synchronously, so it can’t await. use(promise) gets around that: if the promise has a value, use returns it; if it’s pending, use interrupts the render and React shows the nearest <Suspense> fallback, then renders the component again when the promise settles. If the promise rejects, use throws the error and the nearest error boundary shows its fallback. Put the error boundary outside the Suspense, so one wrapper handles both loading and failure. And the promise must be created once (before rendering, or from a cache), never inside the component: a new promise every render means waiting forever.
You’ll be able to: fetch data with use, Suspense and an error boundary, and explain what use actually throws.
1function ProductDetails({ productPromise }) {2 const product = use(productPromise) // suspends while pending3 return <h2>{product.name}</h2>4}56const productPromise = fetchProduct('p4') // started before rendering7root.render(8 <ErrorBoundary fallback={<p>Couldn’t load this product.</p>}>9 <Suspense fallback={<p>Loading product…</p>}>10 <ProductDetails productPromise={productPromise} />11 </Suspense>12 </ErrorBoundary>,13)- trigger
const productPromise = fetchProduct('p4') // started before renderingThe request starts before React renders, and its promise is passed down as a prop. - render phase
const product = use(productPromise) // suspends while pendingThe promise is still pending, souseinterrupts the render here. - commit phase
<Suspense fallback={<p>Loading product…</p>}>React finds the nearest<Suspense>and commits its fallback. - triggerThe promise fulfills. React was listening, and schedules a retry.
- render phase
const product = use(productPromise) // suspends while pendingThis timeusereturns the product synchronously, and the component runs to the end. - commit phaseThe fallback is replaced by the content.
1function ProductDetails({ productPromise }) {2 const product = use(productPromise) // suspends while pending3 return <h2>{product.name}</h2>4}56const productPromise = fetchProduct('p999') // started before rendering7root.render(8 <ErrorBoundary fallback={<p>Couldn’t load this product.</p>}>9 <Suspense fallback={<p>Loading product…</p>}>10 <ProductDetails productPromise={productPromise} />11 </Suspense>12 </ErrorBoundary>,13)- trigger
const productPromise = fetchProduct('p999') // started before renderingThe same start. - commit phase
<Suspense fallback={<p>Loading product…</p>}>Pending, so the fallback shows. - triggerThe promise rejects.
- render phase
const product = use(productPromise) // suspends while pendingOn the retry,usethrows the rejection reason, a real error. React renders once more to be sure, then gives up. - commit phase
<ErrorBoundary fallback={<p>Couldn’t load this product.</p>}>The nearest error boundary, outside theSuspense, shows its fallback.
Promises in two minutes
A promise is a value that stands for a result that isn’t ready yet. It starts pending and settles exactly once, fulfilled with a value or rejected with a reason. .then callbacks never run synchronously, even for a promise that’s already settled: they run after the current code, as microtasks.
const promise = new Promise<string>((resolve) => setTimeout(() => resolve('Desk Lamp'), 100))
log('1. promise created, state: pending')
promise.then((name) => log(`3. then() callback: ${name}`))
log('2. after then(): callbacks never run synchronously')
Promise.resolve('already done').then((v) => log(`2b. even an already-resolved promise waits for a microtask: ${v}`))
log('2a. still synchronous')
1. promise created, state: pending
2. after then(): callbacks never run synchronously
2a. still synchronous
2b. even an already-resolved promise waits for a microtask: already done
3. then() callback: Desk Lamp
Two things Suspense relies on: a promise is an ordinary value you can pass around and store (so React can keep it and attach its own .then), and its state can be checked later without re-running the work.
The example: a product page
function ProductDetails({ productPromise }: { productPromise: Promise<Details> }) {
log(' ProductDetails render started')
const product = use(productPromise) // suspends while the promise is pending
log(' ProductDetails render finished')
return (
<section data-details={product.name}>
<h2>{product.name}</h2>
<p>{formatUSD(product.priceCents)}</p>
</section>
)
}
function ProductPage({ productPromise }: { productPromise: Promise<Details> }) {
return (
<ErrorBoundary fallback={<p data-error>Couldn’t load this product.</p>}>
<Suspense fallback={<p data-fallback>Loading product…</p>}>
<ProductDetails productPromise={productPromise} />
</Suspense>
</ErrorBoundary>
)
}
const productPromise = fetchProduct(scenario === 'error' ? 'p999' : 'p4') // started before rendering
r.render(<ProductPage productPromise={productPromise} />)
The fake API logs each request; screen: lines are what the page showed. Recorded:
0 ms → GET /products/p4
ProductDetails render started
screen: fallback “Loading product…”
ProductDetails render started
300 ms ← GET /products/p4
ProductDetails render started
ProductDetails render finished
screen: details: Desk Lamp
ProductDetails started rendering three times and finished once. The first attempt stopped at use, and the fallback was committed. React then tried again while the request was still pending (React 19 does this to start any sibling requests early) and stopped again. When the data arrived, it rendered to the end.
For a product that doesn’t exist:
0 ms → GET /products/p999
ProductDetails render started
screen: fallback “Loading product…”
ProductDetails render started
300 ms ✗ GET /products/p999: No product with id "p999"
ProductDetails render started
ProductDetails render started
screen: error “Couldn’t load this product.”
The rejection reached the error boundary. Because ErrorBoundary wraps Suspense, it catches failures from anything inside, and the loading state stays the Suspense boundary’s job.
How use works
What does use actually throw while the promise is pending? A try/catch around it shows:
function ProductDetailsTryCatch({ productPromise }: { productPromise: Promise<Details> }) {
try {
const product = use(productPromise)
return <h2 data-details={product.name}>{product.name}</h2>
} catch (thrown) {
log('caught:', thrown === productPromise ? 'the promise itself' : String((thrown as Error).message).split('\n')[0])
throw thrown // rethrow so Suspense still works
}
}
0 ms → GET /products/p4
caught: Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.
screen: fallback “Loading product…”
caught: Suspense Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.
300 ms ← GET /products/p4
screen: details: Desk Lamp
It’s not the promise. In React 19, use throws a special Suspense Exception (“This is not a real error!”) and keeps track of the promise itself. Older Suspense libraries really did throw promise, which is where the common explanation comes from. Either way: don’t catch it. If you must wrap use in try/catch, rethrow, or move use out of the try.
Step by step:
use(promise)checks whether React has seen this promise before. If it’s already fulfilled, it returns the value immediately. That’s why the same promise object matters.- If it’s pending, React attaches
.thento it, throws the Suspense Exception and abandons this component’s render. - React shows the nearest
Suspensefallback (or, in a transition, keeps the current UI; see Promise Caching and Transitions). - When the promise settles, React retries. Fulfilled →
usereturns the value. Rejected →usethrows the reason, a real error, for an error boundary.
use isn’t bound by the rules of hooks: it can be called inside conditions and loops. It also reads context (Context with use).
A new promise every render
function ProductDetailsUncached({ id }: { id: string }) {
const product = use(fetchProduct(id)) // a new promise on every render
return <h2 data-details={product.name}>{product.name}</h2>
}
Recorded over 2.5 seconds:
12
requests, and the page still showed “Loading product…”. Every retry called fetchProduct again, got a new pending promise, and suspended again, forever. Create promises outside render, or get them from a cache keyed by id (Promise Caching and Transitions).
An async component
async function ProductDetailsAsync({ id }: { id: string }) {
const product = await fetchProduct(id)
return <h2 data-details={product.name}>{product.name}</h2>
}
<ProductDetailsAsync> is an async Client Component. Only Server Components can be async at the moment. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.
Async components are a Server Components feature. On the client, React warned, and the component kept re-fetching like the uncached version.
Common mistakes
- Creating the promise during render (recorded: endless requests).
asyncclient components (recorded warning).- Catching what
usethrows without rethrowing. - One
Suspensearound the whole app. Everything inside shows the same fallback; put boundaries around the parts that load independently. - No error boundary. A rejected promise with no boundary above it unmounts the whole root.
Interview Q&A
What does use(promise) do?
It returns the promise’s value if it’s fulfilled. If it’s pending, it suspends the component: React shows the nearest Suspense fallback and retries when the promise settles. If it rejected, it throws the reason for the nearest error boundary.
Why can't a client component just await?
Rendering must be synchronous: React calls the function and needs its result now. use interrupts the render instead of pausing it, and React re-runs the component later. Recorded: an async client component got a warning and re-fetched in a loop.
What does use throw while pending?
In React 19, a special Suspense Exception, not the promise itself; React keeps track of the promise separately. Recorded: a try/catch around use caught “Suspense Exception: This is not a real error!”.
Why must the promise be stable?
React recognizes a promise it has seen settle and returns its value immediately. A new promise each render is always pending, so the component suspends forever. Recorded: 12 requests in 2.5 s and no content.
Why does the error boundary go outside Suspense?
So one boundary catches errors from everything inside, including the suspending component, while Suspense handles only loading. Recorded: the rejection showed “Couldn’t load this product.”
Related
- Promise Caching and Transitions: stable promises and keeping old content.
- Error Boundaries: the error boundary API.
- Code Splitting: Suspense for code instead of data.
Sources
- react.dev:
use,Suspense - MDN: Using promises
- Topic order inspired by Kent C. Dodds’ EpicReact React Suspense workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/use