React use() in try/catch: What the Suspense Error Means
Last month I wanted exactly one line of text on a message card. The card reads a promise with React's use, and when that promise rejected I did not want the whole page collapsing into the nearest error boundary. So I wrapped the read in a try/catch and returned a polite fallback. The page went red instead.
The console had one line worth reading, and it was not mine: Suspense Exception: This is not a real error!
That is the text the use hook throws when it aborts a render, and a try/catch is how you end up reading a message nobody was supposed to see. I have seen the rule dismissed as lint fussiness or as an irrational dislike of defensive code. The reason sits in the reconciler, and once you see it the fix stops being a style preference.
use does not report loading, it throws it
use(promise) has no second return value to give you while the data is in flight, so it aborts the render. It throws, the throw unwinds the call stack, and the Suspense machinery catches it well above your component. That part is expected. What gets thrown is the surprise.
It is not the promise. It is a shared sentinel Error object, and React says why in a comment sitting directly above the throw in ReactFiberThenable.js:
Throwing here is an implementation detail that allows us to unwind the call stack. But we shouldn't allow it to leak into userspace. Throw an opaque placeholder value instead of the actual thenable.
The real promise is parked in a module level variable, suspendedThenable, and after catching the sentinel the work loop picks it back up with getSuspendedThenable(). So your catch block never sees the promise, and it does see an Error whose message is React's boilerplate. That is why logging e.message shows a sentence about Suspense instead of your failed request.
React can also tell that userspace swallowed it. A development only flag, needsToResetSuspendedThenableDEV, is set just before the throw and cleared when the work loop handles the exception. If it is still set at the end of the render, somebody else caught it, and you get a second line on the console:
usewas called from inside a try/catch block. This is not allowed and can lead to unexpected behavior. To handle errors triggered byuse, wrap your component in a error boundary.
The grammar slip in that message is React's, not mine, and it has been there a while. The warning fires once per component name, not once per instance, so the same bad pattern across a list of cards gives you one line to notice. A fine way to miss it for a month.
The code that caused it, and the code that fixed it
Here is what I shipped. It reads sensibly and it is wrong.
function MessageCard({ messagePromise }) {
try {
const message = use(messagePromise);
return <p>{message.text}</p>;
} catch (e) {
return <p>Could not load this message.</p>;
}
}
Delete the try/catch and let a boundary do the work. The nesting matters more than I expected:
function MessageCard({ messagePromise }) {
const message = use(messagePromise);
return <p>{message.text}</p>;
}
<Suspense fallback={<MessageSkeleton />}>
<ErrorBoundary fallback={<p>Could not load this message.</p>}>
<MessageCard messagePromise={messagePromise} />
</ErrorBoundary>
</Suspense>
The error boundary goes inside Suspense, not outside. Put it on the outside and a pending promise reaches it as a thrown value before the fallback ever gets a chance, so every ordinary load looks like a failure. When you want it to forget a previous error, give it a key that changes with the loaded id and it remounts clean.
If dragging in a boundary for one card is too much furniture, catch on the promise before use ever touches it:
const messagePromise = fetchMessage(id).then(
(message) => ({ ok: true, message }),
(error) => ({ ok: false, error }),
);
Catching on the promise instead
Now the promise always fulfils, use never throws, and the card renders its own fallback when result.ok is false. The trade is real. The rejection is gone from the tree, so nothing above the card can react to it. You have also handed yourself a union to narrow at every call site.
Acceptable for a card that is the only consumer. Not acceptable in shared data layer code, where I would rather keep the boundary and the error.
Where else this sentinel turns up
The sentinel can escape into a promise chain too, and React checks for that in production as well, on purpose. If a rejection reason turns out to be the sentinel, you get Hooks are not supported inside an async component with a hint about an accidental 'use client'. Less helpful than the dev warning, but at least it names a cause.
Separately, a component that suspends on a promise it created during render gets told it was suspended by an uncached promise. Read that one twice, because the loop it describes is real. A new promise every render means a new suspension every render, and after more than a hundred suspensions with nothing committed, React gives up and throws An unknown Component is an async Client Component.
The fix is to stop making promises in render. Pass a stable one down from a server component, cache one per id, or hold one in state from an event handler. The streaming dashboard I wrote about uses the first shape.
One more thing worth knowing: useActionState has its own sentinel with a nearly identical message. Do not plan on detecting this situation by string matching a caught error. I know because that was my first instinct, and it would have held until the day React reworded a sentence.
And if boundaries in general have been fighting you, their behaviour around rejected promises deserves its own read. I wrote that up after a Promise.all that never reached its boundary. The boundary-wrapped reader ended up in my Snippet Ark library, because I got tired of retyping it.