{"id":455,"date":"2026-07-13T13:01:17","date_gmt":"2026-07-13T13:01:17","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-error-boundaries-2026-how-i-stopped-shipping-white-screens\/"},"modified":"2026-07-13T13:01:17","modified_gmt":"2026-07-13T13:01:17","slug":"react-error-boundaries-2026-how-i-stopped-shipping-white-screens","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-error-boundaries-2026-how-i-stopped-shipping-white-screens\/","title":{"rendered":"React Error Boundaries in 2026: How I Stopped Shipping White Screens"},"content":{"rendered":"<p>Confession: I shipped the same bug twice in one week. Both times a React app went white on production, both times I opened Sentry and saw nothing, both times the fix was that my error boundary was in the wrong place. I&rsquo;ve been writing React for years. I still get this wrong.<\/p>\n<p>The reason I get it wrong is that &ldquo;error boundary&rdquo; sounds like a catch-all safety net, and it isn&rsquo;t. It catches a very specific kind of error, from a very specific kind of place, and if you don&rsquo;t set it up carefully your users will see the empty page you thought was impossible. So here&rsquo;s what I actually ship now: the boundary component I stopped hand-rolling, where I put it, and how I know it&rsquo;s firing.<\/p>\n<p>Short version for the impatient: use <a href=\"https:\/\/github.com\/bvaughn\/react-error-boundary\" rel=\"nofollow noopener\" target=\"_blank\">react-error-boundary<\/a>, wrap boundaries per feature rather than per app, integrate with Suspense on purpose, and pipe errors into Sentry with <code>onError<\/code>. If you want to know why, read on.<\/p>\n<h2 id=\"why-the-built-in-boundary-keeps-burning-me\">Why the built-in boundary keeps burning me<\/h2>\n<p>React&rsquo;s <a href=\"https:\/\/react.dev\/reference\/react\/Component#catching-rendering-errors-with-an-error-boundary\" rel=\"nofollow noopener\" target=\"_blank\">official docs<\/a> still show a class component. That&rsquo;s not a bug in the docs. The boundary API relies on <code>getDerivedStateFromError<\/code> and <code>componentDidCatch<\/code>, and both are class-only. There&rsquo;s no hook. Not <code>useErrorBoundary<\/code>, not <code>use()<\/code>, nothing. As of React 19 you still have to write a class if you want to roll your own.<\/p>\n<p>That&rsquo;s fine on paper. In practice I&rsquo;d copy-paste this thing between projects, forget to add a <code>reset<\/code> handler, forget to log the error anywhere useful, and end up with a boundary that catches errors and then quietly hides them behind a friendly &ldquo;something went wrong&rdquo; message that never gets fixed.<\/p>\n<p>Here&rsquo;s the class I used to ship, and it was worse than nothing:<\/p>\n<pre><code class=\"language-jsx\">class ErrorBoundary extends React.Component {\n  state = { hasError: false }\n\n  static getDerivedStateFromError() {\n    return { hasError: true }\n  }\n\n  componentDidCatch(error, info) {\n    console.error(error, info) \/\/ dev-only. useless in prod.\n  }\n\n  render() {\n    if (this.state.hasError) return &lt;p&gt;Something went wrong.&lt;\/p&gt;\n    return this.props.children\n  }\n}\n<\/code><\/pre>\n<p>Two problems. There&rsquo;s no way to reset it without unmounting the whole subtree, and <code>console.error<\/code> in production is a black hole. If a user hit a bad request that only fires on one route, they&rsquo;d get a permanent &ldquo;something went wrong&rdquo; until they refreshed the tab. I watched this happen in a real session replay and wanted to walk into the sea.<\/p>\n<h2 id=\"what-i-actually-ship-now-react-error-boundary\">What I actually ship now: react-error-boundary<\/h2>\n<p>The <a href=\"https:\/\/github.com\/bvaughn\/react-error-boundary\" rel=\"nofollow noopener\" target=\"_blank\"><code>react-error-boundary<\/code><\/a> library from Brian Vaughn has been the answer for years, and I don&rsquo;t know why I resisted it for so long. It gives you an <code>&lt;ErrorBoundary&gt;<\/code> component plus a <code>useErrorBoundary<\/code> hook, both of which handle reset properly and take a real <code>onError<\/code> callback for logging.<\/p>\n<p>Here&rsquo;s the same code, replaced:<\/p>\n<pre><code class=\"language-jsx\">import { ErrorBoundary } from &quot;react-error-boundary&quot;\nimport * as Sentry from &quot;@sentry\/react&quot;\n\nfunction Fallback({ error, resetErrorBoundary }) {\n  return (\n    &lt;div role=&quot;alert&quot; className=&quot;p-4 border rounded&quot;&gt;\n      &lt;p className=&quot;font-semibold&quot;&gt;Something broke loading this section.&lt;\/p&gt;\n      &lt;p className=&quot;text-sm opacity-70&quot;&gt;{error.message}&lt;\/p&gt;\n      &lt;button onClick={resetErrorBoundary} className=&quot;mt-2 underline&quot;&gt;\n        Try again\n      &lt;\/button&gt;\n    &lt;\/div&gt;\n  )\n}\n\nexport function FeatureShell({ children }) {\n  return (\n    &lt;ErrorBoundary\n      FallbackComponent={Fallback}\n      onError={(error, info) =&gt; Sentry.captureException(error, { extra: info })}\n      onReset={() =&gt; {\n        \/\/ clear query cache for this feature, reset local state, etc.\n      }}\n    &gt;\n      {children}\n    &lt;\/ErrorBoundary&gt;\n  )\n}\n<\/code><\/pre>\n<p>Two things that made a real difference. First, <code>resetErrorBoundary<\/code> is a proper API. I can wire it to a &ldquo;Try again&rdquo; button and the boundary re-mounts its children without a full page refresh. Second, <code>onError<\/code> fires on every catch, so I stopped losing errors in production the moment I plugged Sentry into it. That&rsquo;s most of the value right there.<\/p>\n<h2 id=\"suspense-boundaries-are-a-different-problem-entirely\">Suspense boundaries are a different problem entirely<\/h2>\n<p>This one took me embarrassingly long to internalize. React error boundaries do <strong>not<\/strong> catch errors thrown during rendering that happen inside a Suspense boundary while it&rsquo;s suspending. If you throw a promise (which is how Suspense works under the hood via <code>use()<\/code> and libraries like React Query), the error propagates up until it either resolves or hits a boundary.<\/p>\n<p>Every <code>&lt;Suspense&gt;<\/code> you add is also a thing that needs a boundary next to it, usually just outside. I cover this pattern in more detail in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/react-suspense-in-production-what-i-actually-use-it-for\/\" rel=\"noopener\">React Suspense in production<\/a>, but the short version is:<\/p>\n<pre><code class=\"language-jsx\">&lt;ErrorBoundary FallbackComponent={Fallback} onError={reportError}&gt;\n  &lt;Suspense fallback={&lt;Skeleton \/&gt;}&gt;\n    &lt;UserProfile userId={id} \/&gt;\n  &lt;\/Suspense&gt;\n&lt;\/ErrorBoundary&gt;\n<\/code><\/pre>\n<p>The order matters. <code>ErrorBoundary<\/code> outside, <code>Suspense<\/code> inside. Get this backwards and a rejected promise from a <code>use()<\/code> hook will kill the fallback UI too, which is somehow worse than not having a fallback at all.<\/p>\n<h2 id=\"where-i-put-boundaries-in-a-real-app\">Where I put boundaries in a real app<\/h2>\n<p>I used to slap one boundary at the root of the app and call it done. That&rsquo;s the version that gives you the &ldquo;whole app white-screens on a single component&rsquo;s bug&rdquo; experience. Now I follow three rules.<\/p>\n<p>At the root, one boundary catches the truly unexpected: a null reference in a shared layout component, a broken import, a runtime version mismatch. Its fallback is a full-page &ldquo;this page crashed, please reload&rdquo; screen with a reload button.<\/p>\n<p>Per route, one boundary so a crash in <code>\/settings<\/code> doesn&rsquo;t blank out <code>\/dashboard<\/code>. Next.js has this built in via <a href=\"https:\/\/nextjs.org\/docs\/app\/getting-started\/error-handling\" rel=\"nofollow noopener\" target=\"_blank\"><code>error.tsx<\/code><\/a> files at the route level, which I use verbatim. In React Router you wrap each route element.<\/p>\n<p>Per risky widget, one boundary. Anything that hits a third-party API, does complex chart rendering, or accepts user-generated markdown gets its own. If the charting library throws, the rest of the settings page still works.<\/p>\n<p>Three levels, chosen deliberately. When I started doing this the number of full-app crashes reported by users dropped to basically zero, and the ones that still happen are almost always shared-layout bugs that deserve a full-page fallback anyway.<\/p>\n<h2 id=\"hooking-up-sentry-without-the-noise\">Hooking up Sentry without the noise<\/h2>\n<p>The one nuance I wish someone had told me sooner: don&rsquo;t just pipe every <code>onError<\/code> straight into <code>Sentry.captureException<\/code> unfiltered. You will drown in noise from network blips, aborted requests, and React StrictMode double-invocations in development.<\/p>\n<p>I use <a href=\"https:\/\/docs.sentry.io\/platforms\/javascript\/guides\/react\/features\/error-boundary\/\" rel=\"nofollow noopener\" target=\"_blank\">Sentry&rsquo;s React SDK<\/a> with two guardrails. First, I check the error type before capturing:<\/p>\n<pre><code class=\"language-jsx\">onError={(error, info) =&gt; {\n  \/\/ Ignore aborted fetches; they're not real errors.\n  if (error.name === &quot;AbortError&quot;) return\n  Sentry.captureException(error, {\n    contexts: { react: { componentStack: info.componentStack } },\n  })\n}}\n<\/code><\/pre>\n<p>Second, I set a rate limit on the client-side sample rate for lower-severity errors. Sentry&rsquo;s own <code>ErrorBoundary<\/code> wrapper does most of this for you if you use <code>Sentry.ErrorBoundary<\/code> directly instead of the raw library, and I&rsquo;ve been happy with that on newer projects. But whichever you pick, pass the <code>componentStack<\/code> in explicitly. It&rsquo;s the difference between a Sentry issue you can debug in five minutes and one you stare at for an hour.<\/p>\n<h2 id=\"the-one-thing-to-do-this-week\">The one thing to do this week<\/h2>\n<p>Open the biggest React app you own. Search the codebase for <code>ErrorBoundary<\/code>. Count the results. If it&rsquo;s one, add three: one at the route level, one around your data-fetching subtree, one around whatever the sketchiest widget is. If it&rsquo;s zero, install <code>react-error-boundary<\/code>, wire it into Sentry (or whatever error tracker you use; I write about this kind of pipeline work over on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio<\/a>), and ship it before Friday. It takes an hour. You will find at least one silent error the next time you deploy.<\/p>\n<p>I still get boundary placement wrong sometimes. The last one that bit me was a third-party analytics script throwing inside a memoized child during a render pass, which I hadn&rsquo;t wrapped because &ldquo;analytics can&rsquo;t crash.&rdquo; It can. Wrap it anyway.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I broke a React app twice in one week because an error boundary never fired. Here&#8217;s the setup I actually ship in 2026 with react-error-boundary and Sentry.<\/p>\n","protected":false},"author":2,"featured_media":454,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I broke a React app twice in one week because an error boundary never fired. Here's the setup I actually ship in 2026 with react-error-boundary and Sentry.","rank_math_focus_keyword":"react error boundary","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[35],"tags":[505,38,44,41,506],"class_list":["post-455","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-development","tag-error-boundaries","tag-frontend","tag-javascript","tag-react","tag-sentry"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/455","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=455"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/455\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/454"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=455"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=455"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=455"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}