Skip to content

React Error Boundaries in 2026: How I Stopped Shipping White Screens

React Error Boundaries in 2026: How I Stopped Shipping White Screens

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’ve been writing React for years. I still get this wrong.

The reason I get it wrong is that “error boundary” sounds like a catch-all safety net, and it isn’t. It catches a very specific kind of error, from a very specific kind of place, and if you don’t set it up carefully your users will see the empty page you thought was impossible. So here’s what I actually ship now: the boundary component I stopped hand-rolling, where I put it, and how I know it’s firing.

Short version for the impatient: use react-error-boundary, wrap boundaries per feature rather than per app, integrate with Suspense on purpose, and pipe errors into Sentry with onError. If you want to know why, read on.

Why the built-in boundary keeps burning me

React’s official docs still show a class component. That’s not a bug in the docs. The boundary API relies on getDerivedStateFromError and componentDidCatch, and both are class-only. There’s no hook. Not useErrorBoundary, not use(), nothing. As of React 19 you still have to write a class if you want to roll your own.

That’s fine on paper. In practice I’d copy-paste this thing between projects, forget to add a reset 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 “something went wrong” message that never gets fixed.

Here’s the class I used to ship, and it was worse than nothing:

class ErrorBoundary extends React.Component {
  state = { hasError: false }

  static getDerivedStateFromError() {
    return { hasError: true }
  }

  componentDidCatch(error, info) {
    console.error(error, info) // dev-only. useless in prod.
  }

  render() {
    if (this.state.hasError) return <p>Something went wrong.</p>
    return this.props.children
  }
}

Two problems. There’s no way to reset it without unmounting the whole subtree, and console.error in production is a black hole. If a user hit a bad request that only fires on one route, they’d get a permanent “something went wrong” until they refreshed the tab. I watched this happen in a real session replay and wanted to walk into the sea.

What I actually ship now: react-error-boundary

The react-error-boundary library from Brian Vaughn has been the answer for years, and I don’t know why I resisted it for so long. It gives you an <ErrorBoundary> component plus a useErrorBoundary hook, both of which handle reset properly and take a real onError callback for logging.

Here’s the same code, replaced:

import { ErrorBoundary } from "react-error-boundary"
import * as Sentry from "@sentry/react"

function Fallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" className="p-4 border rounded">
      <p className="font-semibold">Something broke loading this section.</p>
      <p className="text-sm opacity-70">{error.message}</p>
      <button onClick={resetErrorBoundary} className="mt-2 underline">
        Try again
      </button>
    </div>
  )
}

export function FeatureShell({ children }) {
  return (
    <ErrorBoundary
      FallbackComponent={Fallback}
      onError={(error, info) => Sentry.captureException(error, { extra: info })}
      onReset={() => {
        // clear query cache for this feature, reset local state, etc.
      }}
    >
      {children}
    </ErrorBoundary>
  )
}

Two things that made a real difference. First, resetErrorBoundary is a proper API. I can wire it to a “Try again” button and the boundary re-mounts its children without a full page refresh. Second, onError fires on every catch, so I stopped losing errors in production the moment I plugged Sentry into it. That’s most of the value right there.

Suspense boundaries are a different problem entirely

This one took me embarrassingly long to internalize. React error boundaries do not catch errors thrown during rendering that happen inside a Suspense boundary while it’s suspending. If you throw a promise (which is how Suspense works under the hood via use() and libraries like React Query), the error propagates up until it either resolves or hits a boundary.

Every <Suspense> 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 React Suspense in production, but the short version is:

<ErrorBoundary FallbackComponent={Fallback} onError={reportError}>
  <Suspense fallback={<Skeleton />}>
    <UserProfile userId={id} />
  </Suspense>
</ErrorBoundary>

The order matters. ErrorBoundary outside, Suspense inside. Get this backwards and a rejected promise from a use() hook will kill the fallback UI too, which is somehow worse than not having a fallback at all.

Where I put boundaries in a real app

I used to slap one boundary at the root of the app and call it done. That’s the version that gives you the “whole app white-screens on a single component’s bug” experience. Now I follow three rules.

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 “this page crashed, please reload” screen with a reload button.

Per route, one boundary so a crash in /settings doesn’t blank out /dashboard. Next.js has this built in via error.tsx files at the route level, which I use verbatim. In React Router you wrap each route element.

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.

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.

Hooking up Sentry without the noise

The one nuance I wish someone had told me sooner: don’t just pipe every onError straight into Sentry.captureException unfiltered. You will drown in noise from network blips, aborted requests, and React StrictMode double-invocations in development.

I use Sentry’s React SDK with two guardrails. First, I check the error type before capturing:

onError={(error, info) => {
  // Ignore aborted fetches; they're not real errors.
  if (error.name === "AbortError") return
  Sentry.captureException(error, {
    contexts: { react: { componentStack: info.componentStack } },
  })
}}

Second, I set a rate limit on the client-side sample rate for lower-severity errors. Sentry’s own ErrorBoundary wrapper does most of this for you if you use Sentry.ErrorBoundary directly instead of the raw library, and I’ve been happy with that on newer projects. But whichever you pick, pass the componentStack in explicitly. It’s the difference between a Sentry issue you can debug in five minutes and one you stare at for an hour.

The one thing to do this week

Open the biggest React app you own. Search the codebase for ErrorBoundary. Count the results. If it’s one, add three: one at the route level, one around your data-fetching subtree, one around whatever the sketchiest widget is. If it’s zero, install react-error-boundary, wire it into Sentry (or whatever error tracker you use; I write about this kind of pipeline work over on my portfolio), and ship it before Friday. It takes an hour. You will find at least one silent error the next time you deploy.

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’t wrapped because “analytics can’t crash.” It can. Wrap it anyway.