Skip to main content

Error Boundaries

What you'll learn

  • Error boundaries as the error twin of Suspense
  • Exactly what they catch, and the four things they famously don't
  • The mechanism: unwind, discard, fallback, report
  • Writing one today with the community-standard pattern
  • Three reset strategies and when each fits
  • Placement strategy, and why wrapping everything is an anti-pattern

Suspense catches "not ready yet". But what catches "this went wrong"? Without protection, a single thrown error during render takes down your entire app, React unmounts the whole tree and the user gets a blank white page. Error boundaries are the other half of the story: a component that catches render-time errors below it and swaps in fallback UI, so one broken widget never bankrupts the page.

The error twin of Suspense

The shape will feel familiar:

<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<BuggyWidget />
</ErrorBoundary>

Read it with the same sentence pattern as Suspense:

"If anything inside this box breaks while rendering, show this fallback instead, and leave the rest of the app alone."

The symmetry runs deep:

SuspenseError boundary
Catchesthrown promises (not ready)thrown errors (broken)
Fallback showsuntil promise resolvesuntil you reset it
Recoveryautomatic (retry on resolve)explicit (retry button, key change)
Rest of appunaffectedunaffected

One boundary family, two failure modes of rendering: can't finish yet and can't finish ever.

Jargon: "error boundary". A component designated to catch errors thrown during rendering anywhere below it in the tree. When one fires, React discards the broken subtree and renders the boundary's fallback in its place.

What they catch: and what they don't

Precision matters here, because the "don't" list surprises everyone.

Error boundaries catch:

  • Errors during rendering, a component function throws (e.g., reading .name off undefined).
  • Errors in lifecycle-like code below, the commit-phase machinery that runs effects and class lifecycles for the subtree.
  • Errors in other boundaries below, a boundary catches errors from descendants, including from boundaries nested deeper that failed to handle their own.

Error boundaries do NOT catch:

  • Event handler errors. Your onClick runs outside render, it's your code executing in a browser event. Use ordinary try/catch in the handler.
function DeleteButton() {
function handleClick() {
try {
deleteEverything(); // if this throws, NO boundary will catch it
} catch (error) {
report(error); // handle it yourself
}
}
return <button onClick={handleClick}>Delete</button>;
}
  • Async errors. Promise rejections, setTimeout callbacks, fetch failures, these happen after render is long over. .catch() them or try/catch your awaits.
useEffect(() => {
fetch('/api/data')
.then((r) => r.json())
.catch((error) => setLoadError(error)); // your job, not the boundary's
}, []);
  • Errors thrown by the boundary itself. A boundary catches errors below it, not in it. If the boundary's own fallback rendering throws, the error travels further up, to the next boundary above, if any.
  • Server-side rendering. On the server there's no long-lived tree to keep alive; an error during server render fails that render. (Server errors are their own topic in Part 7.)

The mnemonic: boundaries guard render, not events, not async, not themselves.

The mechanism, conceptually

When a component throws during render, here's the sequence:

  1. React unwinds up the tree to the nearest error boundary (skipping over Suspense boundaries, they only catch promises).
  2. React discards the broken subtree entirely. Unlike a suspended tree, a crashed tree can't be resumed, its state is gone, its DOM is removed. It rendered garbage halfway; there's nothing trustworthy to preserve.
  3. React renders the boundary's fallback in the empty slot and commits it.
  4. React reports the error so you can log it. The root you created your app with accepts conceptual options like onCaughtError (a boundary handled this) and onUncaughtError (nothing caught this, it's escaping), your hook for shipping errors to a monitoring service.

Pseudocode model, not real source:

// Conceptually, when render throws an error:
function handleRenderError(error, whereItThrew) {
const boundary = findNearestErrorBoundaryAbove(whereItThrew);
if (!boundary) {
unmountWholeApp();
rootOptions.onUncaughtError?.(error);
return;
}
discardSubtree(boundary.children); // state and DOM are gone
boundary.hasErrored = true;
commit(boundary.fallback); // swap in the safety UI
rootOptions.onCaughtError?.(error); // log it somewhere useful
}

Contrast this with Suspense's retry loop: a promise resolves, so retrying makes sense. An error doesn't "resolve", retrying the same render with the same inputs would throw the same error forever. That's why error recovery is explicit: something must actually change before a retry is worthwhile.

Writing one today: the community pattern

React's built-in boundary API is class-based and awkward; in practice everyone uses the tiny community package react-error-boundary, which wraps it in friendly props. Here's a complete, realistic example, a counter that throws when it hits 3, with a working "Try again":

import { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function BuggyCounter() {
  const [count, setCount] = useState(0);

  if (count === 3) {
    // Render-time error: exactly what boundaries exist for
    throw new Error('Counter exploded at 3!');
  }

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      Count: {count} (click me 3 times)
    </button>
  );
}

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" className="error-box">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

export default function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <BuggyCounter />
    </ErrorBoundary>
  );
}

What happens:

  1. You click: 1, 2, the counter renders fine.
  2. On the third click, setCount(3) triggers a render, and BuggyCounter throws during render.
  3. React unwinds to the ErrorBoundary, discards the counter subtree, and commits ErrorFallback with the error in hand.
  4. You click "Try again" → resetErrorBoundary() flips the boundary out of its errored state and remounts the children from scratch. BuggyCounter mounts fresh with count = 0 and works again.

Notice step 4: reset means remount, not resume. The old counter, including its poisonous count: 3 state, was discarded in step 3. The retry gets a clean slate. That's the only safe retry there is.

The library offers two interchangeable fallback shapes, FallbackComponent (a component, as above) and fallbackRender (an inline function receiving the same props). Same machinery, pick your style.

Three reset strategies

A fallback that can't be escaped is a dead end. You have three tools, from most to least manual:

1. Explicit reset action, the "Try again" button above. Best when the user can plausibly succeed on retry (a transient blip, an action they can redo differently).

2. resetKeys, the boundary resets itself automatically when any value in the array changes:

import { useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { UserDashboard } from './UserDashboard';
import { ErrorFallback } from './ErrorFallback';

export default function App() {
  const [userId, setUserId] = useState(1);

  return (
    <div>
      <button onClick={() => setUserId((id) => id + 1)}>Next user</button>
      <ErrorBoundary FallbackComponent={ErrorFallback} resetKeys={[userId]}>
        <UserDashboard userId={userId} />
      </ErrorBoundary>
    </div>
  );
}

What happens: if user 1's dashboard crashes the boundary, clicking "Next user" changes userId, and since it's in resetKeys, the boundary resets and renders user 2's dashboard cleanly. Best when a crash is likely tied to specific inputs, and changing inputs should naturally clear it.

3. Remount via key, the sledgehammer: render the boundary itself with a key, and bump the key to force React to throw the whole boundary away and mount a fresh one. Same trick you'd use for any "start this subtree over" moment.

All three rest on the same underlying fact: recovery = new inputs + a fresh mount, never a resume of the crashed tree.

Placement strategy: degrade gracefully, isolate loudly

Where boundaries go determines the blast radius of a bug:

  • Page-level boundaries, one per route. If anything on the page crashes, the user sees a friendly "this page broke, here's a link home" instead of a white void. This is the floor every app should have.
  • Section-level boundaries, around independent widgets: the comments section, the recommendations carousel, the third-party embed. A crash in recommendations shouldn't take the article down with it. Isolate what can fail independently.
  • NEVER around everything by default, don't wrap every component "just in case". Fine-grained boundaries everywhere hide bugs: errors get swallowed by the nearest tiny boundary, the app looks fine in testing while quietly rendering fallbacks, and you lose the signal that something is wrong. Boundaries are for zones where graceful degradation makes UX sense, not for suppressing errors.

A good default: one boundary per route, plus one around each self-contained widget that talks to the outside world.

Dev vs prod: the overlay

The same crash behaves differently depending on the build:

  • Development: React's error overlay (or your framework's) hijacks the screen with the full error and stack trace, even if a boundary caught it. This is deliberate, a boundary catching an error in dev must never slow you down from seeing it. Dismiss the overlay and you'll find the boundary's fallback rendered underneath.
  • Production: no overlay; the boundary's fallback is simply what the user sees. Quiet, graceful, and, if you wired up error reporting, logged to your monitoring service.

So don't panic when a boundary "doesn't work" in dev: it is working; the overlay is just insisting you look at the error first.

Throwing on purpose is good, actually

One last mindset flip. Developers sometimes contort components to avoid throwing, returning null when data is malformed, rendering empty divs for impossible states. This trades a loud bug for a silent one.

For genuinely "this should never happen" states, throwing is the correct move:

function StatusBadge({ status }) {
if (status === 'active') return <span className="green">Active</span>;
if (status === 'closed') return <span className="gray">Closed</span>;

// Impossible by contract — if we get here, something upstream is broken.
throw new Error(`Unknown status: ${status}`);
}

What happens: instead of a mysteriously blank badge that nobody notices for three weeks, you get an immediate crash in development, a captured error in your monitoring, and, in production, a contained fallback from the nearest boundary instead of a corrupted page. The error surfaces where it can be found and fixed. Boundaries make this safe; silence makes bugs immortal.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: Error boundaries catch every error in your app. Reality: only render-time (and lifecycle-like) errors below them, not event handlers, not async, not their own, not the server's.
  • Misconception: A caught error means the subtree can resume where it left off. Reality: the broken subtree is discarded; recovery is always a fresh remount with (hopefully) different inputs.
  • Misconception: You should wrap every component in a boundary for safety. Reality: boundaries everywhere hide bugs and fragment UX; place them per route and per independent widget.
  • Misconception: If a boundary caught it, users are fine, no need to log. Reality: the fallback hides the fire; without error reporting you'll never know it happened.
  • Misconception: The dev overlay means your boundary is broken. Reality: dev always surfaces the error first; dismiss the overlay and the fallback is right there.
  • Misconception: Throwing in render is sloppy coding. Reality: for impossible states, throwing loudly, caught by a boundary in prod, surfaced in dev, is the most maintainable choice.

Why it works this way

  • Render errors are unrecoverable by definition. The render produced nonsense; there's no safe "keep going". Discarding the subtree is the only honest move, purity means we know exactly what to throw away.
  • Explicit resets force a real fix. Automatic retry would loop forever on a deterministic bug. Requiring new inputs (a click, a key change) guarantees the retry differs from the crash.
  • Events and async are out of scope on purpose. Handlers and promises run outside render's pure window; there's no tree position to "unwind to" at that moment. Plain try/catch is the right tool because the context is yours, not React's.
  • Graceful degradation beats white screens. A page with a dead comments widget is still a page. Boundaries convert total failures into partial ones, the defining trait of resilient UI.
  • Loud errors get fixed. The whole design, overlay in dev, reporting hooks in prod, "throw on impossible states", optimizes for bugs being seen, because seen bugs die.

Try it yourself

  1. Build the BuggyCounter example. Click to 3. Expected: the fallback appears with the error message, and the rest of the page (add some siblings to prove it) keeps working. Hit "Try again", the counter restarts at 0.
  2. Move the throw into the click handler instead of render. Expected: the boundary does not catch it, check the console for an uncaught error. Now wrap the handler body in try/catch and confirm that's the right tool.
  3. Add resetKeys={[userId]} to a boundary around a component that crashes for a specific userId. Trigger the crash, then change the id. Expected: the boundary resets itself with no button involved.
  4. Remove every boundary and throw during render. Expected: the entire app unmounts to a blank screen. Re-add one route-level boundary and repeat, the difference is the entire point of this chapter.

Recap

  • Error boundaries catch render-time errors below them and swap in fallback UI, saving the rest of the app.
  • They do not catch event handler errors, async rejections, errors in the boundary itself, or server render errors.
  • Mechanism: unwind to nearest boundary → discard the broken subtree → commit fallback → report the error.
  • Use the community pattern (react-error-boundary) with FallbackComponent/fallbackRender and a real reset path.
  • Recovery is always a remount: explicit reset, resetKeys, or a bumped key.
  • Place boundaries per route and per independent widget, never around everything.
  • Dev shows the error overlay first; prod shows the fallback. Throwing for impossible states is a feature, not a sin.

Next

createRoot and the first render →