Catching Render Errors with Error Boundaries
Learn what error boundaries catch and don't catch, how to build a class-based error boundary with a fallback UI, where to place boundaries at the app and route level, and how they interact with Suspense.
A single thrown error deep inside a component tree can, by default, unmount your entire React application and leave the user staring at a blank white screen. Error boundaries exist to contain that blast radius — they let you catch rendering errors in a subtree, log them, and show a fallback UI instead of crashing the whole app.
What error boundaries catch (and what they don't)
An error boundary is a component that catches JavaScript errors thrown anywhere in its child component tree during rendering, in lifecycle methods, and in constructors of the whole tree below it. When one of its descendants throws, the boundary catches the error, logs it, and renders a fallback UI instead of the broken subtree — the rest of the application outside that boundary keeps working normally.
Error boundaries do NOT catch errors inside event handlers — a throw inside an onClick handler does not trigger the boundary, because that code runs outside of React's render call stack; you handle those with an ordinary try/catch. They also do not catch errors in asynchronous code such as setTimeout callbacks or fetch promise chains, errors during server-side rendering, and errors thrown inside the error boundary's own fallback rendering code (an error in the fallback itself will propagate up to the next boundary above it, if any).
This narrow, specific scope is intentional: error boundaries are for the class of bugs where rendering itself is broken — a component receives data it cannot render, a required prop is missing, or a downstream API contract changed — not for general-purpose error handling. Async errors and event-handler errors need their own handling strategy at the call site.
Building a class-based boundary
As of today, error boundaries can only be implemented as class components — there is no hook equivalent (no useErrorBoundary hook exists in React). This is a deliberate, still-current gap: the error-catching behavior relies on two class lifecycle methods, static getDerivedStateFromError and componentDidCatch, that have no functional/hook counterpart. If you want error-boundary behavior in a function-component-based codebase, you still write one small class component and use it as a wrapper — you don't need to convert anything else in your app to classes.
static getDerivedStateFromError(error) is called during the render phase after a descendant throws; it receives the error and returns a value used to update state, which you then use to render a fallback UI on the next render. componentDidCatch(error, errorInfo) is called during the commit phase, after the fallback has already been rendered; this is the method to use for side effects like logging the error to a reporting service, because it runs after render, not during it — errorInfo.componentStack gives you the component tree location where the error occurred, which is invaluable for debugging.
Because most teams write this boundary once and reuse it everywhere, it is common to keep a single generic ErrorBoundary component in a shared location and pass it a fallback prop (or render prop) so different parts of the app can customize the fallback UI without duplicating the class.
Placement: app-level vs route-level boundaries
A single boundary wrapped around your entire app is the simplest setup and guarantees no error ever produces a blank screen, but it's a blunt instrument: any error anywhere replaces the whole UI with one fallback, including parts of the page that were working fine. In practice, most production apps use a layered strategy — one top-level boundary as a last-resort safety net, plus additional boundaries around individual routes, or around risky, data-heavy widgets (a chart, a third-party embed, a rich text editor).
Route-level boundaries mean that if the 'Reports' page throws, the navigation sidebar, header, and the rest of the app remain interactive — only the broken route's content area shows the fallback. This granularity is usually the right default for any app with more than a couple of screens, since it keeps a failure in one feature from taking down unrelated features the user might still want to use.
Recovery is also boundary-scoped: a well-built fallback UI includes a 'Try again' action that resets the boundary's error state (typically by re-mounting the subtree, often via changing a key or calling a reset callback you expose from the boundary), letting the user retry without a full page reload if the underlying condition was transient, like a flaky network response.
Error boundaries and Suspense
Suspense and error boundaries solve two different problems that often sit side by side: Suspense shows a fallback while a component is *waiting* (loading data or code), while an error boundary shows a fallback when a component *failed*. A component wrapped in <Suspense fallback={...}> that is lazy-loaded with React.lazy(), or that throws a promise while fetching data, will show the Suspense fallback while pending — but if the underlying import or fetch ultimately fails (rejects), that failure surfaces as a thrown error during render, which only an error boundary — not Suspense on its own — will catch.
The common pattern is to nest an error boundary directly around (outside of) a Suspense boundary: <ErrorBoundary fallback={<ErrorUI/>}><Suspense fallback={<Spinner/>}><LazyWidget/></Suspense></ErrorBoundary>. This gives you three UI states for the same subtree — normal content, a loading spinner while pending, and an error fallback if either the lazy import or the data fetch fails — cleanly separated by which component is responsible for which concern.
Minimal class-based error boundary
getDerivedStateFromError must be static and pure — it only computes new state, it must not perform side effects like logging. componentDidCatch is where logging/reporting belongs, because it runs after the fallback has committed. There is currently no hook equivalent for this class — function-component codebases still write this one small class.
import React from "react";
type Props = { fallback: React.ReactNode; children: React.ReactNode };
type State = { hasError: boolean };
class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(_error: unknown): State {
return { hasError: true };
}
componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) {
// Side effect: report to logging service. Runs in the commit phase.
console.error("ErrorBoundary caught:", error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export default ErrorBoundary;Code Examples
Changing key on the boundary forces React to discard and remount the entire subtree, including the boundary's own error state, giving the user a genuine retry rather than reusing a component instance that's already in a broken state.
Common mistake: Calling setState inside the boundary to just flip hasError back to false without remounting children can leave stale, already-corrupted state in the children that caused the crash in the first place.
Suspense handles the pending/loading state for the lazy import; the error boundary sitting outside it handles the case where the dynamic import or the widget's own data fetch throws. Each concern is handled by the component designed for it. Expected result: While loading: 'Loading reports...' — if the import fails: 'Could not load reports. Please refresh.'
import React, { useState } from "react";
import ErrorBoundary from "./ErrorBoundary";
function Fallback({ onRetry }: { onRetry: () => void }) {
return (
<div role="alert">
<h2>Something went wrong.</h2>
<button onClick={onRetry}>Try again</button>
</div>
);
}
function App() {
const [resetKey, setResetKey] = useState(0);
return (
<ErrorBoundary
key={resetKey}
fallback={<Fallback onRetry={() => setResetKey((k) => k + 1)} />}
>
<Dashboard />
</ErrorBoundary>
);
}import React, { Suspense } from "react";
import ErrorBoundary from "./ErrorBoundary";
const ReportsWidget = React.lazy(() => import("./ReportsWidget"));
function ReportsRoute() {
return (
<ErrorBoundary fallback={<p>Could not load reports. Please refresh.</p>}>
<Suspense fallback={<p>Loading reports...</p>}>
<ReportsWidget />
</Suspense>
</ErrorBoundary>
);
}What an error boundary catches
Only errors thrown during React's own render/lifecycle work are caught — event handlers and async callbacks need their own error handling.
render()
|
Child throws -----> [ getDerivedStateFromError ] (render phase: compute fallback state)
|
re-render with fallback UI
|
[ componentDidCatch ] (commit phase: log/report)
NOT caught by the boundary:
onClick={() => { throw ... }} <-- event handler, use try/catch
fetch(...).then(() => { throw ... }) <-- async callback, use .catch()Important Rules
Error boundaries must currently be class components — there is no hook-based equivalent.
They catch errors in rendering, lifecycle methods, and constructors of the tree below them, not in event handlers or async callbacks.
Place at least one top-level boundary as a safety net, and add route-level or widget-level boundaries so one failure doesn't take down the whole UI.
An error thrown inside a fallback itself is not caught by that same boundary — it propagates to the next boundary up the tree.
Pair an error boundary with Suspense by wrapping the Suspense boundary, so load-failures and pending states are each handled by the right mechanism.
Where this shows up
A production dashboard with independent widgets (a chart, a live feed, a third-party embed) typically wraps each widget in its own error boundary: if the third-party embed's SDK throws during render, only that one widget shows 'Unable to load this panel' while the rest of the dashboard — navigation, other charts, the user's data — keeps working uninterrupted.
function ErrorBoundary({ children }: { children: React.ReactNode }) { // trying to catch errors with a hook -- this does not exist const [error, setError] = useState<Error | null>(null); return error ? <p>Error!</p>: children; } class ErrorBoundary extends React.Component<Props, State> { static getDerivedStateFromError(error: unknown) { return { hasError: true }; } componentDidCatch(error: unknown, info: React.ErrorInfo) { /* log */ } render() { return this.state.hasError ? this.props.fallback: this.props.children; } }
There is no hook that catches render errors thrown by children; only the class lifecycle methods getDerivedStateFromError and componentDidCatch participate in React's error-boundary contract.
<button onClick={() => { throw new Error("bad"); }}>Click</button> // wrapped in an ErrorBoundary, expecting it to show the fallback
<button onClick={() => { try { doRiskyThing(); } catch (e) { reportError(e); setLocalErrorState(e); } }}>Click</button>
Errors thrown inside event handlers happen outside React's render call stack, so no error boundary will catch them — handle these with a local try/catch instead.
Which of these will an error boundary catch by default? A. An error thrown inside an onClick handler B. An error thrown inside a component's render logic C. A promise rejection inside a setTimeout callback D. An error thrown while calling fetch() inside a useEffect's async callback
B. An error thrown inside a component's render logic — Error boundaries catch errors thrown during React's rendering work (render, lifecycle methods, constructors) in the subtree below them. Event handlers and async callbacks run outside that call stack and need their own error handling.
Why must an error boundary currently be written as a class component? A. Because function components cannot render fallback UI B. Because the error-catching contract relies on getDerivedStateFromError and componentDidCatch, which have no hook equivalent C. Because TypeScript does not support error handling in function components D. Because Suspense requires all its ancestors to be classes
B. Because the error-catching contract relies on getDerivedStateFromError and componentDidCatch, which have no hook equivalent — React's error-boundary mechanism is implemented through two class lifecycle methods that have no corresponding hook, so a small class component is still required even in an otherwise all-function-component codebase.
An ErrorBoundary wraps a <Suspense> boundary around a lazily-loaded component whose dynamic import fails. What happens? A. Suspense's fallback is shown forever B. Nothing renders and no fallback appears C. The failed import throws during render, which the outer ErrorBoundary catches and renders its fallback D. React automatically retries the import indefinitely
C. The failed import throws during render, which the outer ErrorBoundary catches and renders its fallback — Suspense only handles the pending state; when the underlying import (or data fetch) actually fails, that failure becomes a thrown error during render, which is exactly what an error boundary placed outside the Suspense boundary is designed to catch.
You have an app with a single top-level ErrorBoundary around the whole <App/>. Users report that when the 'Notifications' bell in the header throws due to a malformed API response, the entire application — including the sidebar and main content — disappears. Explain why, and describe the change you'd make.
With only one boundary at the very top, any thrown error anywhere in the tree is caught by that same top-level boundary, which then renders its fallback in place of everything, including unrelated parts of the app like the sidebar. The fix is to wrap just the Notifications component in its own, more local error boundary, so a failure there shows a small inline fallback (e.g., a broken-bell icon) while the rest of the app, still outside that inner boundary, continues rendering normally.
Think about how far up the tree an uncaught render error propagates before it finds a boundary.