[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-react-use-hook-try-catch-not-a-real-error":3},{"content":4,"lastModified":5},"\u003Cp>Last month I wanted exactly one line of text on a message card. The card reads a promise with React's \u003Ccode>use\u003C\u002Fcode>, 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\u002Fcatch and returned a polite fallback. The page went red instead.\u003C\u002Fp>\n\n\u003Cp>The console had one line worth reading, and it was not mine: \u003Ccode>Suspense Exception: This is not a real error!\u003C\u002Fcode>\u003C\u002Fp>\n\n\u003Cp>That is the text the use hook throws when it aborts a render, and a try\u002Fcatch 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.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1633356122544-f134324a6cee?auto=format&amp;fit=crop&amp;w=1200&amp;q=80\" alt=\"Two monitors side by side, a code editor full of JSX on the left and a browser on localhost:3000 showing the React starter page and its spinning atom logo on the right\" loading=\"lazy\" \u002F>\n\n\u003Ch2>use does not report loading, it throws it\u003C\u002Fh2>\n\n\u003Cp>\u003Ccode>use(promise)\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>ReactFiberThenable.js\u003C\u002Fcode>:\u003C\u002Fp>\n\n\u003Cblockquote>\n\u003Cp>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.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\n\u003Cp>The real promise is parked in a module level variable, \u003Ccode>suspendedThenable\u003C\u002Fcode>, and after catching the sentinel the work loop picks it back up with \u003Ccode>getSuspendedThenable()\u003C\u002Fcode>. So your catch block never sees the promise, and it does see an Error whose message is React's boilerplate. That is why logging \u003Ccode>e.message\u003C\u002Fcode> shows a sentence about Suspense instead of your failed request.\u003C\u002Fp>\n\n\u003Cp>React can also tell that userspace swallowed it. A development only flag, \u003Ccode>needsToResetSuspendedThenableDEV\u003C\u002Fcode>, 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:\u003C\u002Fp>\n\n\u003Cblockquote>\n\u003Cp>\u003Ccode>use\u003C\u002Fcode> was called from inside a try\u002Fcatch block. This is not allowed and can lead to unexpected behavior. To handle errors triggered by \u003Ccode>use\u003C\u002Fcode>, wrap your component in a error boundary.\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>The code that caused it, and the code that fixed it\u003C\u002Fh2>\n\n\u003Cp>Here is what I shipped. It reads sensibly and it is wrong.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">function MessageCard({ messagePromise }) {\n  try {\n    const message = use(messagePromise);\n    return &lt;p&gt;{message.text}&lt;\u002Fp&gt;;\n  } catch (e) {\n    return &lt;p&gt;Could not load this message.&lt;\u002Fp&gt;;\n  }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Delete the try\u002Fcatch and let a boundary do the work. The nesting matters more than I expected:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-jsx\">function MessageCard({ messagePromise }) {\n  const message = use(messagePromise);\n  return &lt;p&gt;{message.text}&lt;\u002Fp&gt;;\n}\n\n&lt;Suspense fallback={&lt;MessageSkeleton \u002F&gt;}&gt;\n  &lt;ErrorBoundary fallback={&lt;p&gt;Could not load this message.&lt;\u002Fp&gt;}&gt;\n    &lt;MessageCard messagePromise={messagePromise} \u002F&gt;\n  &lt;\u002FErrorBoundary&gt;\n&lt;\u002FSuspense&gt;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ccode>key\u003C\u002Fcode> that changes with the loaded id and it remounts clean.\u003C\u002Fp>\n\n\u003Cp>If dragging in a boundary for one card is too much furniture, catch on the promise before \u003Ccode>use\u003C\u002Fcode> ever touches it:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-js\">const messagePromise = fetchMessage(id).then(\n  (message) =&gt; ({ ok: true, message }),\n  (error) =&gt; ({ ok: false, error }),\n);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch2>Catching on the promise instead\u003C\u002Fh2>\n\n\u003Cp>Now the promise always fulfils, \u003Ccode>use\u003C\u002Fcode> never throws, and the card renders its own fallback when \u003Ccode>result.ok\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Ch2>Where else this sentinel turns up\u003C\u002Fh2>\n\n\u003Cp>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 \u003Ccode>Hooks are not supported inside an async component\u003C\u002Fcode> with a hint about an accidental \u003Ccode>'use client'\u003C\u002Fcode>. Less helpful than the dev warning, but at least it names a cause.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ccode>An unknown Component is an async Client Component\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Cp>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 \u003Ca href=\"\u002Fposts\u002Freact-server-components-dashboard-performance\u002F\">streaming dashboard\u003C\u002Fa> I wrote about uses the first shape.\u003C\u002Fp>\n\n\u003Cp>One more thing worth knowing: \u003Ccode>useActionState\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>And if boundaries in general have been fighting you, their behaviour around rejected promises deserves its own read. I wrote that up after \u003Ca href=\"\u002Fposts\u002Fpromise-all-error-boundary-not-firing\u002F\">a Promise.all that never reached its boundary\u003C\u002Fa>. The boundary-wrapped reader ended up in my \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> library, because I got tired of retyping it.\u003C\u002Fp>\n","2026-10-11",1791775771990]