#Next.js#React

React Error Boundaries and Exception Handling

Does one component throw and blank the screen? Error Boundaries contain crashes locally. Learn Error Boundary usage (class components + getDerivedStateFromError), errors it cannot catch (events, async code, SSR), react-error-boundary, and a complete frontend exception-handling strategy.

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

A production alert arrives: a page has gone blank, and a user reports, “everything suddenly disappeared.” You check the monitoring dashboard and find that an unremarkable list component read undefined.map while rendering and threw a TypeError. That single exception took down the entire page.

This is React's default crash model: if any component throws during rendering, the entire component tree is unmounted and the page goes blank. React is not being overly strict; it genuinely has no other choice. Halfway through rendering, its internal state machine may already be corrupted. Continuing could desynchronize the UI and data, producing a wildly incorrect interface. React decisively stops when it needs to stop, at the cost that developers must actively use an Error Boundary to contain crashes locally. Otherwise, a bug in a peripheral component can take down the whole page.

But Error Boundaries are not omnipotent. They only handle the “render phase”; they cannot catch event handlers, setTimeout, Promises, or any of those other cases. This is one of the most common traps for beginners. This article explains how to write Error Boundaries, what they catch, what they cannot catch, and how to combine them with global reporting (Sentry) into a complete frontend exception-handling strategy.

1. React's Crash Model: One throw Blanks the Entire Tree

First, understand why React appears “fragile.” Before Error Boundaries existed (React 15 and earlier), a component throwing meant a console full of errors, a UI stuck in a corrupted intermediate state, and all kinds of strange interactions. After React 16 introduced the Fiber architecture, the rule became explicit: any unhandled exception during rendering unmounts the entire tree and immediately blanks the page. The official position is roughly this: it is better to show nothing than to show an incorrect UI.

This behavior is correct in itself. Showing an incorrect UI is more dangerous than a blank screen because users may make decisions based on bad data. But it puts the issue squarely in front of you: you must proactively use Error Boundaries to contain crashes, or every part of the page goes down together. A typical case: the homepage's main content is a data dashboard, with a small “popular events” card beside it. Today, the card's API returns an unusual structure, and data.items.map blows up. The dashboard disappears with it. The dashboard did nothing wrong, yet it has to go down because of a small card. Error Boundaries exist to ensure that when the small card crashes, the crash stays within that small card while the dashboard continues working normally.

2. Writing an Error Boundary: A Class Component and Two Lifecycle Methods

An Error Boundary must be a class component. At present, React does not provide an equivalent API for function components; neither componentDidCatch nor getDerivedStateFromError has a Hook version. A minimal usable Error Boundary looks like this:

tsx
import { Component, type ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback: ReactNode;
}

interface State {
  hasError: boolean;
}

class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  // Called during rendering: return new state and trigger fallback rendering
  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  // Called during commit: receives the error and component stack; suitable for reporting
  componentDidCatch(error: Error, info: { componentStack: string }) {
    reportError({ error, componentStack: info.componentStack });
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

The two lifecycle methods have different responsibilities, so do not confuse them:

  • getDerivedStateFromError(error): called during the render phase. The returned state is used immediately in the current update. Its sole purpose is to “switch the component to its fallback.” Do not perform side effects here such as making requests or logging, because side effects are not allowed during rendering.
  • componentDidCatch(error, info): called during the commit phase, after the UI has switched to its fallback. This is where reporting logs and calling console.error belong. info.componentStack is the component stack assembled by React, not the JavaScript call stack. It tells you which component failed and is extremely valuable for debugging.

Usage is one sentence: wrap it around the area you want to protect.

tsx
<ErrorBoundary fallback={<div>This section failed, but we are still here</div>}>
  <FlakyCard />
</ErrorBoundary>

Next.js App Router establishes a convention here: an error.tsx file is a route-level Error Boundary, and the framework automatically treats it as a fallback. Note that it must be a client component ("use client") because error recovery depends on state, and state runs only on the client. Rather than the older reset, this version of error.tsx receives unstable_retry. Calling it refetches and rerenders the route segment, as covered in more detail in the recovery section below.

3. What It Cannot Catch: Event Handlers, Async Code, and SSR

This is the largest “gotcha” with Error Boundaries: they only catch errors thrown by their child tree during rendering and lifecycle methods. They cannot handle any of the following:

  • Event handlers: throw inside onClick, and the Error Boundary remains unaffected. Event handling occurs outside rendering, so React cannot catch it this way.
  • Async code: exceptions inside setTimeout, setInterval, or requestAnimationFrame happen after rendering has already finished.
  • Promise / async-await rejections: likewise, they happen outside the async boundary that Error Boundaries handle.
  • SSR errors: there is no commit phase during server-side rendering, so Error Boundaries do not work on the server.
  • Errors thrown by the Error Boundary itself: a boundary cannot save itself. Do not put complex logic in a fallback.

What should you do with errors that cannot be caught this way? Use native JavaScript mechanisms honestly:

tsx
function SaveButton() {
  const handleClick = async () => {
    try {
      await saveData(); // Async errors cannot be handled by an Error Boundary
    } catch (err) {
      reportError(err);
      toast.error("Save failed, please retry");
    }
  };
  return <button onClick={handleClick}>Save</button>;
}

Keep this mental model: Error Boundaries cover “render-time crashes,” try/catch covers “interaction-time failures,” and global listeners cover “anything that slips through.” Each covers a different segment, and all three are necessary. One counterintuitive but useful detail: if you wrap heavy event work in useTransition's startTransition, unhandled errors will bubble to the nearest Error Boundary. This effectively lets “interaction errors” benefit from a boundary fallback.

4. react-error-boundary: A Function-Component-Friendly Wrapper

Writing a class every time is tedious, and classes stand out in codebases built primarily around function components. The community library react-error-boundary packages this layer for you with a deliberately restrained API:

tsx
import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary
  fallbackRender={({ error, resetErrorBoundary }) => (
    <div>
      <p>An error occurred: {error.message}</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  )}
  onError={(error, info) => reportError({ error, info })}
  onReset={() => {
    // Usually reset the external state that triggered the error here, such as clearing a corrupted query cache
    queryClient.invalidateQueries({ queryKey: ["flaky"] });
  }}
>
  <FlakyChart />
</ErrorBoundary>

Its extra capability is the important part: resetErrorBoundary. It lets an Error Boundary recover from its fallback state. This version of Next.js App Router has an official counterpart as well: unstable_retry in error.tsx refetches and rerenders the route segment. If you want a component-level boundary not tied to a route, you can wrap a fallback component with unstable_catchError exported by next/error. The effect is similar to a handwritten class, but without writing one yourself. Choose any of these three approaches; the core idea is always “fallback + recovery.”

5. Recovery: Give Users a “Retry” Button Instead of a Dead Page

Many people stop after writing a fallback and rendering a red “An error occurred” message. That is equivalent to making users refresh a dead page in their browser, which is a terrible experience. A good Error Boundary is recoverable.

The core idea of recovery is to reset the part of the state that caused the crash, then rerender. A common approach for a handwritten boundary is to give the Error Boundary a new key. It will unmount and rebuild, naturally clearing its internal state:

tsx
// Force the boundary to rebuild with a key
const [retryKey, setRetryKey] = useState(0);

<ErrorBoundary key={retryKey} fallback={<RedBox onRetry={() => setRetryKey(k => k + 1)} />}>
  <FlakyList />
</ErrorBoundary>

resetErrorBoundary from react-error-boundary and Next.js's unstable_retry both package “reset + rerender” for you, but the principle is the same. A more thorough approach integrates the data layer. The root cause of most rendering crashes is “bad data in the cache.” Before retrying, clear the corresponding react-query / SWR query, or retrying a hundred times will produce the same failure.

One hard rule: fallback UI must be static and dependency-free. Do not read context in a fallback, do not fetch, and do not use any logic that could throw again. The fallback is your last line of defense. If it crashes too, there is truly nothing left to save you.

6. Layout Strategy: Local Boundaries Plus a Root Boundary

Where you place Error Boundaries determines the scope affected by a crash. I recommend a two-layer layout:

  1. Root-level Error Boundary: wraps the outermost part of the entire application and serves as the last line of defense. It catches exceptions that no local boundary intercepted. Its fallback is usually a complete “application crashed” page with actions such as “Back to home” and “Contact support.” In Next.js App Router, this corresponds to app/global-error.tsx. It replaces the root layout, so it must include its own <html> and <body>.
  2. Local Error Boundaries around critical areas: wrap independent, error-prone, non-core sections, such as side cards, third-party charts, rich-text editors, ad placements, and user-generated content. The principle is that a failure in this area should not affect the main functionality.
tsx
// Root level (outside providers / layout)
<ErrorBoundary fallback={<AppCrash />}>
  <App />
  {/* Also wrap each error-prone module with a local boundary */}
  <ErrorBoundary fallback={<ChartSkeleton />}>
    <RevenueChart />
  </ErrorBoundary>
</ErrorBoundary>

One commonly overlooked detail: the nesting order of Suspense and Error Boundary matters. If you want separate fallbacks for “loading” and “failed loading,” ErrorBoundary must be outside Suspense. Otherwise, Error Boundary captures the Promise thrown by Suspense first and the behavior becomes confused:

tsx
// Correct order: ErrorBoundary outside, Suspense inside
<ErrorBoundary fallback={<Error />}>
  <Suspense fallback={<Spinner />}>
    <ComponentThatSuspends />
  </Suspense>
</ErrorBoundary>

7. Global Reporting and Source Maps: Make Stacks Readable

componentDidCatch in an Error Boundary only receives render-phase errors. To collect all exceptions, add global listeners at the browser level as well:

ts
// Synchronous errors
window.addEventListener("error", (event) => {
  reportError({ type: "window.error", error: event.error });
});

// Unhandled Promise rejections
window.addEventListener("unhandledrejection", (event) => {
  reportError({ type: "unhandledrejection", reason: event.reason });
});

Send all three sources, Error Boundary, window.error, and unhandledrejection, to a unified reportError, then batch-send them to Sentry or your own logging service. If you use Sentry, you do not need to assemble this yourself: @sentry/react provides Sentry.ErrorBoundary and automatically registers global hooks, so you can use its wrapper directly.

Finally, there is a mandatory production step: upload source maps. Production JavaScript is compressed and minified, so reported stacks look like this:

text
at o.a.render (main.7f3b2c.js:2:18453)
at ... (main.7f3b2c.js:2:18990)

A stack like this is effectively no report at all. The approach is to generate source maps during the build, but do not deploy them to production servers to prevent source exposure. Instead, use @sentry/cli or Sentry's Webpack/Vite plugin to upload the maps directly to Sentry. After Sentry receives an error, it uses the map to restore the stack, so you see a readable original stack and source context. In Next.js, this is nearly zero configuration. Once Sentry is integrated, add an upload step in CI; the framework already handles the generation and upload details for you.

Summary

  1. If any React component throws during rendering, the entire tree goes blank. This is intentional to avoid showing a corrupted UI, but it means developers must proactively use Error Boundaries to contain crashes locally or every part of the page goes down together.
  2. An Error Boundary must be a class component and relies on two lifecycle methods: getDerivedStateFromError to switch to the fallback, and componentDidCatch to report with a component stack. Next.js App Router uses error.tsx / global-error.tsx as route-level wrappers and requires "use client"; component-level boundaries can use unstable_catchError from next/error.
  3. It cannot catch errors in event handlers, async work (setTimeout/Promise), or SSR. Use try/catch for those, then use window.onerror and unhandledrejection for anything that slips through. The three mechanisms each cover a different segment. Unhandled errors in startTransition are an exception and bubble to a boundary.
  4. The react-error-boundary library provides a function-component-friendly wrapper and resetErrorBoundary. Combine “clear bad data + rerender” so users can recover by clicking “Retry” instead of refreshing a dead page. A fallback must be static and dependency-free because it is the last line of defense.
  5. Use two layers of boundaries: root-level plus local boundaries for critical areas. ErrorBoundary must wrap Suspense from the outside. In production, always upload source maps to Sentry; otherwise reported stacks are only minified coordinates and cannot be investigated.

Key takeaway: Error Boundaries do not prevent errors; they ensure that when an error occurs, only one room collapses instead of the entire building. Give render crashes to boundaries, interaction errors to try/catch, and anything that slips through to global listeners. Three lines of defense plus readable source maps are what complete exception handling looks like.

Related

Related Content