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
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:
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 callingconsole.errorbelong.info.componentStackis 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.
<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, orrequestAnimationFramehappen 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:
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:
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:
// 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:
- 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>. - 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.
// 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:
// 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:
// 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:
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
- 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.
- An Error Boundary must be a class component and relies on two lifecycle methods:
getDerivedStateFromErrorto switch to the fallback, andcomponentDidCatchto report with a component stack. Next.js App Router useserror.tsx/global-error.tsxas route-level wrappers and requires"use client"; component-level boundaries can useunstable_catchErrorfromnext/error. - It cannot catch errors in event handlers, async work (
setTimeout/Promise), or SSR. Usetry/catchfor those, then usewindow.onerrorandunhandledrejectionfor anything that slips through. The three mechanisms each cover a different segment. Unhandled errors instartTransitionare an exception and bubble to a boundary. - The
react-error-boundarylibrary provides a function-component-friendly wrapper andresetErrorBoundary. 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. - 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
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.