skip to content

In React 19, what happens when a promise read with `use()` rejects, and why can't you handle it by wrapping the `use()` call in try/catch?

level: middleimportance: should knowfreq 36%

answer

  1. failure leaves the component by throwing
  2. pending and rejected use the same channel
  3. a local catch swallows the wait signal
  4. boundary above, or catch on the promise
  5. no boundary means the tree unmounts

basics

~20 s

A rejection is thrown out of the component during render, so the nearest error boundary above it renders its fallback. React does not support try/catch around use(), because that catch would also intercept the throw React uses to signal suspension.

solid answer

~40 s

`use(promise)` has two failure-shaped outcomes and both leave the component by throwing: a pending promise throws to signal suspension, and a rejected one throws the rejection reason. React does not support wrapping the call in `try`/`catch`, because a local `catch` cannot tell those two apart and would swallow control flow React needs. There are two supported strategies. Put an error boundary above the component, and it renders its fallback when the read fails — the same boundary that catches any other render error. Or make the promise not reject at all: attach `.catch()` where the promise is created, returning a sentinel value, so `use` fulfills with that value and the component renders a degraded UI itself. If nothing catches the error, it reaches the root and React unmounts the tree.

code

javascript · 6 lines
javascript
// Attach the fallback where the promise is created, not in render.
export function loadUser(id) {
  return fetch('/api/user/' + id)
    .then((r) => r.json())
    .catch(() => null); // use() now always receives a fulfilled promise
}

go deeper

for a junior

Know that a rejected promise read by use() is not returned as a value — the error is thrown, and an error boundary placed above the component shows the fallback UI.

for a middle

Explain that suspension and rejection both leave the component by throwing, so a local try/catch would swallow React's wait signal; name the two supported paths, a boundary above or a .catch() on the promise.

for a senior

Decide per resource which failure mode the UI should have: degrade with a sentinel value for optional data, contain with a boundary when the subtree cannot render, and never leave a data-reading subtree with no boundary above it at all.

for a principal

Own the failure-containment policy across the app — how far up boundaries sit, what each blast radius costs, and how failed reads surface to monitoring rather than silently degrading into empty regions.

## The two throws During render, `use(promise)` looks at the promise's status: - **pending** → React needs to stop rendering this component and come back later. Internally it signals that by throwing, which unwinds out of your component function. - **rejected** → the rejection reason is thrown out of the component, exactly like any other error raised during render. - **fulfilled** → the value is returned normally. Both non-happy paths therefore travel by the same mechanism: an exception leaving your function. That single fact explains the whole error story. ## Why try/catch is not supported ```jsx function Profile({ userPromise }) { try { const user = use(userPromise); // React does not support this return <h1>{user.name}</h1>; } catch { return <p>Could not load</p>; } } ``` A `catch` block here catches *whatever* comes out of `use`, and on the very first render that is React's suspension signal, not a failure. Swallowing it means React never learns that the component needs to wait; the component would render its error branch on the first pass and the pending case would silently become the failure case. React documents that `use` cannot be called inside a `try`/`catch` block for this reason. This is not a special quirk of `use`. Render-time errors in React have always been handled by boundaries rather than by local `try`/`catch`, precisely because a component's render is re-entrant and controlled by React. ## Strategy 1 — an error boundary above the component An error boundary is a component that catches errors thrown while rendering its subtree and renders fallback UI instead. Placed above the component that calls `use`, it turns a rejected data read into a visible, contained failure: ```jsx <ErrorBoundary fallback={<p>Could not load the profile.</p>}> <Suspense fallback={<p>Loading…</p>}> <Profile userPromise={userPromise} /> </Suspense> </ErrorBoundary> ``` The pairing is the point: the Suspense boundary owns "not yet", the error boundary owns "never". Both are decisions made by an ancestor about how much of the UI a missing or failed resource is allowed to take down. If no boundary sits above the component, the error propagates to the root and React unmounts the whole tree rather than leaving a partially rendered UI on screen. That is the harshest failure mode and the reason a data-reading subtree should almost always sit under some boundary. ## Strategy 2 — never reject The alternative is to handle the failure before the promise ever reaches the component, by attaching `.catch()` where the promise is created: ```js const userPromise = fetchUser(id).catch(() => null); ``` Now the promise always fulfills — with `null` on failure — so `use` returns `null` and the component branches on it like any other value. This keeps the failure local and non-destructive, which suits an optional widget where a missing value is acceptable but blanking a whole region is not. The tradeoff is honest: a sentinel value loses the error's detail, and it puts failure handling in the component's normal rendering logic. Reach for it for optional data; reach for a boundary when the failure genuinely means the subtree cannot render. ## Choosing between them Ask what the UI should be when the data is unavailable. If the answer is "this whole region shows an error state", a boundary is the right tool and it also covers every other error in that subtree. If the answer is "render without it", make the promise resolve to a fallback value and let the component decide. Mixing them is fine too: catch to a sentinel for the parts you can degrade, and rely on the boundary for the parts you cannot. ## The misconception to avoid Candidates often assume `use` returns something error-shaped — `null`, `undefined`, or a `[data, error]` pair. It does not. Success returns the value; failure leaves the component entirely. Once that is clear, the reason `try`/`catch` is off the table follows immediately.

  • Where exactly should the .catch() be attached if you want a fallback value?
    Where the promise is created — outside the component's render, alongside the request itself. Attaching it there means the promise handed to use() always fulfills, so the component receives a sentinel value instead of throwing, and the fallback logic lives with the code that knows what the request meant.
  • What does the user see if the promise rejects and no error boundary exists above the component?
    The error propagates to the root and React unmounts the entire tree, leaving a blank page rather than a half-rendered one. That is deliberate — React treats an uncaught render error as leaving the UI in an unknown state — and it is why data-reading subtrees are normally placed under a boundary.
  • Does the Suspense fallback stay on screen when the promise rejects?
    No. The fallback covers the pending phase; once the promise rejects, the throw travels past it to the nearest error boundary, which replaces that region with error UI. So the two boundaries handle disjoint outcomes — waiting versus failing — rather than one covering for the other.

saying these in an interview costs you the question

  • Says use() returns null or an error object on rejection
  • Wraps the use() call in try/catch as the normal pattern
  • Expects the Suspense fallback to stay visible after a rejection
  • Thinks React retries a rejected promise automatically
  • Assumes an uncaught render error leaves the rest of the page intact

context