open atlas
↑ Back to track
React, zero to senior RCT · 09 · 01

Error boundaries: the class-only net and its four blind spots

A render crash with no boundary unmounts the whole tree. Boundaries are class-only: getDerivedStateFromError swaps in fallback state, componentDidCatch reports componentStack. Handlers, async, SSR and the boundary itself escape — cover the gaps with route and widget tiers.

RCT Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

Black Friday, 09:14. The recommendations carousel on the product page started throwing — the feed returned a discontinued SKU without a price object, and one card’s render called price.amount.toFixed(2) on undefined. There was no error boundary anywhere in the tree. Since React 16 the contract is brutal: an uncaught render error unmounts the entire root. So it was not the carousel that died — the whole page went white: gallery, add-to-cart, navigation, footer. Forty-three minutes of blank screens on the highest-traffic day of the year, while the on-call engineer stared at a single minified line: Minified React error #310. The fix that shipped that afternoon was eleven lines — a class component with getDerivedStateFromError wrapped around the carousel. The fix that actually mattered took a quarter longer: understanding why the next incident, a throw inside an onClick handler, sailed straight past that shiny new boundary and left a silently dead button instead of a fallback.

In ten minutes you’ll know exactly which errors a boundary catches, which four categories slip past it, and how to close those gaps before the next on-call shift inherits them.

Why React unmounts everything — and what a boundary is

React 16 made a deliberate call: a corrupted UI is worse than no UI. If a render throws and nothing catches it, React cannot know which part of the committed tree is now lying to the user — a payment form might show the wrong amount, a medical dashboard the wrong patient. So it unmounts the whole root. An error boundary is how you opt back into partial survival: a component that tells React “if anything below me throws during rendering, I will render something sane instead.”

A boundary is a class component that implements one or both of two lifecycles, and they split the work along React’s render/commit phases:

class WidgetBoundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    // render phase: must be pure — derive fallback state, nothing else
    return { error };
  }

  componentDidCatch(error, errorInfo) {
    // commit phase: side effects allowed — report once, with the React tree
    reportError(error, errorInfo.componentStack);
    // componentStack reads like: "in PriceTag / in Card / in Carousel / in App"
  }

  render() {
    if (this.state.error) return <CarouselFallback />;
    return this.props.children;
  }
}

getDerivedStateFromError(error) runs during the render phase, while React is unwinding from the throw — it must be pure, and its job is to return state that switches render() onto the fallback branch. componentDidCatch(error, errorInfo) runs in the commit phase, where side effects are legal — that is where reporting belongs, and it is the only place that hands you errorInfo.componentStack: the chain of components that contained the crash, which is different from the JS stack (the chain of function calls that produced it). You usually want both in the report.

Why class-only? Because catching a render-phase throw needs a lifecycle that the reconciler can invoke synchronously mid-unwind. A function component has no such attachment point — its entire body is the render, so there is nowhere to hang the catch without re-running the body. There is no hook equivalent, and the React docs are explicit about it. Every function-component “boundary” you have seen wraps a class internally.

Quiz

A team puts the Sentry call inside getDerivedStateFromError because that is where the error arrives first. What is wrong with that?

The four blind spots

Boundaries catch errors thrown during rendering, in lifecycle methods, and in constructors of the tree below them. Everything else escapes, and the four escapes surprise teams in production. Before you ship a boundary, ask yourself: does the thing most likely to crash in this widget live inside render — or in a handler, an async call, or the fallback itself?

  1. Event handlers. An onClick throw happens outside rendering — React has no tree to repair, so it does not involve boundaries at all. The error propagates to window as an uncaught exception, the UI stays exactly as it was, and the user gets a button that “does nothing”. You own the try/catch.
  2. Async code. setTimeout callbacks, promise chains, the body of an async effect — by the time these throw, the render call stack is long gone. Rejected promises surface as unhandledrejection, not as a caught render error.
  3. Server-side rendering. Class boundaries are a client-runtime mechanism. During streaming SSR an error is handled by the server entry point (onError of the stream API) and the nearest Suspense fallback — not by your componentDidCatch.
  4. The boundary itself. A throw inside the boundary’s own render — most often inside a fancy fallback — propagates to the next boundary above. If the root boundary’s fallback fetches translations and that fetch code throws, you are back to the white screen. Fallbacks must be boring on purpose.

Together these four categories mean that a boundary alone is not a complete error strategy — it handles the render path, and you own every other path explicitly. Miss even one and you get a silently dead UI with a healthy error dashboard.

The first two blind spots have a practical bridge. The de-facto standard wrapper, react-error-boundary, packages the class for you and adds the routing primitive:

import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";

function ExportButton() {
  const { showBoundary } = useErrorBoundary();
  return (
    <button
      onClick={async () => {
        try {
          await exportReport();   // handler + async: two blind spots at once
        } catch (err) {
          showBoundary(err);      // route it INTO the nearest boundary
        }
      }}
    >
      Export
    </button>
  );
}

<ErrorBoundary
  FallbackComponent={ReportFallback}  // receives { error, resetErrorBoundary }
  onError={(error, info) => reportError(error, info.componentStack)}
>
  <ReportPanel />
</ErrorBoundary>

showBoundary(err) makes the nearest boundary render its fallback as if the error had happened in render — one consistent fallback path for render crashes and the failures you caught manually. onError centralizes reporting so each boundary does not reinvent it.

Quiz

A payment widget is wrapped in an ErrorBoundary. The throw happens inside its onSubmit handler. What does the user see in production?

Placement: route tier plus widget tier

Where you put boundaries is an availability design decision, because each boundary defines a blast radius and owes the user a meaningful fallback:

  • Root tier — the last resort. Fallback is a full-page “something broke, reload” screen. Blast radius is still 100%, but a designed 100% instead of a white screen. Every app needs exactly one.
  • Route tier — one boundary per page-level route. The shell, navigation and other routes stay alive; the fallback offers a path back. Blast radius: one page.
  • Widget tier — around independent, risky islands: third-party embeds, charts fed by external data, anything rendering user-generated content. The Hook’s carousel belongs here. Blast radius: one card out of twenty.

The anti-pattern is wrapping every component “to be safe”: dozens of generic “oops” fallbacks fragment the page into apologetic holes, and nobody designs a real recovery for any of them. Tiers force the right question per tier — what can the user still do here?

One production nuance: in development the React DevTools overlay surfaces errors even when a boundary catches them — teams regularly conclude their boundary is broken when it is working as designed. In production the user sees only your fallback; the message itself is minified to an error code plus a decoder link, and componentStack is only readable after source maps are applied. React 19 also cleaned up reporting: caught errors are logged once through the root’s onCaughtError instead of duplicated to the console — the pipeline side of that story is lesson 03.

Why this works

Why has React never shipped a useErrorBoundary hook in core? The catch has to happen while the reconciler unwinds from a throw — a synchronous interception point between an exception and the commit. Class lifecycles give the reconciler named slots it can call mid-unwind; a function component offers no such slot, because its whole body is the render that just exploded — re-running it to “ask about the error” would re-run the crash. The honest answer is that the primitive is fine as a class and wrapping it costs nothing: react-error-boundary is a thin class plus context, and your codebase needs roughly one wrapper, not one boundary per feature. The hooks era changed how components are written, not how exceptions propagate through a tree.

Recall before you leave
  1. 01
    Name the two boundary lifecycles, the phase each runs in, and which one should do the reporting.
  2. 02
    List the four blind spots of error boundaries and the practical countermeasure for each.
Recap

Since React 16, an uncaught error in rendering unmounts the entire root — React refuses to leave a possibly-lying UI on screen, so without a boundary one bad cell takes down the whole page. An error boundary is a class component that opts a subtree back into partial survival through two lifecycles split across React’s phases: getDerivedStateFromError runs purely during the render-phase unwind and returns the state that switches the boundary onto its fallback; componentDidCatch runs in the commit phase, fires once per caught error, and is the right home for reporting because side effects are legal there and it alone receives componentStack — the component chain, distinct from and complementary to the JS stack. Boundaries are class-only because the reconciler needs a synchronous interception slot mid-unwind, which a function body cannot provide; react-error-boundary wraps the class and adds FallbackComponent, onError, and useErrorBoundary, whose showBoundary routes manually caught errors into the same fallback path. The contract has exactly four blind spots — event handlers (uncaught to window, UI silently intact), async code (unhandledrejection, stack long gone), SSR (the stream’s onError plus Suspense, not componentDidCatch), and the boundary’s own render, including its fallback, which escapes one level up. Placement is an availability decision by blast radius: one boring root boundary as the last resort, route-tier boundaries that keep the shell alive, widget-tier boundaries around risky islands like third-party embeds — and not one boundary per component, because each fallback is a designed promise about what the user can still do. In dev the overlay shows caught errors anyway; in production the user sees only your fallback and the logs hold minified error codes that need source maps and, since React 19, flow once through the root’s error callbacks. Now when you see a handler throw land silently or a button stop working with no dashboard alert, you’ll know to reach for try/catch plus showBoundary — not another boundary wrapper.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.