Must knowReact Suspense · 01

Data Fetching with use

Promises, use(promise), Suspense and error boundaries, and what really happens when use “throws”.

On this page · 8 sections
  1. In one minute
  2. Promises in two minutes
  3. The example: a product page
  4. How use works
  5. Common mistakes
  6. Interview Q&A
  7. Related
  8. Sources

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.

Fulfilled
ProductPage.tsx
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)
What you see
(blank)
Console (recorded)
0 ms → GET /products/p4
ProductDetails render started
screen: Loading product…
300 ms ← GET /products/p4
ProductDetails render finished
  1. triggerconst productPromise = fetchProduct('p4') // started before renderingThe request starts before React renders, and its promise is passed down as a prop.
  2. render phaseconst product = use(productPromise) // suspends while pendingThe promise is still pending, so use interrupts the render here.
  3. commit phase<Suspense fallback={<p>Loading product…</p>}>React finds the nearest <Suspense> and commits its fallback.
  4. triggerThe promise fulfills. React was listening, and schedules a retry.
  5. render phaseconst product = use(productPromise) // suspends while pendingThis time use returns the product synchronously, and the component runs to the end.
  6. commit phaseThe fallback is replaced by the content.

0 / 0
A product page whose data takes 300 ms to arrive (both runs recorded).

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:

  1. 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.
  2. If it’s pending, React attaches .then to it, throws the Suspense Exception and abandons this component’s render.
  3. React shows the nearest Suspense fallback (or, in a transition, keeps the current UI; see Promise Caching and Transitions).
  4. When the promise settles, React retries. Fulfilled → use returns the value. Rejected → use throws 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

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.”

Sources

Primary source: react.dev/reference/react/use