Overview
An error boundary is the React-tree equivalent of a try/catch. It catches errors thrown during render, lifecycle, and constructors of components below it, and renders a fallback in place of the broken subtree. It does not catch errors in event handlers, async code, or server-side rendering paths handled by the framework. Pair error boundaries with React Query, SWR, or server-component error files for the surfaces they cannot reach. This page covers what they catch, how to design the fallback, how to log, and how to reset.
Boundaries catch render-tree errors, nothing else
An error boundary catches errors thrown during the render of a descendant component. It does not catch:
- Errors thrown inside event handlers (
onClick,onChange). Usetry/catchinline. - Errors thrown in async callbacks (
setTimeout,fetch().then). Usetry/catchor a promise.catch. - Errors thrown during server-side rendering. The framework handles those through
error.tsxin Next.js 15. - Errors thrown in the error boundary itself. Wrap it in a parent boundary if recovery matters.
The boundary is one part of an error story, not the whole thing. The rest lives in data libraries, route-level error files, and explicit try/catch.
Write the boundary once, reuse it
React still requires a class component for the lifecycle methods. Write one ErrorBoundary component and reuse it; do not hand-roll a new boundary per route.
"use client";
import { Component, type ReactNode } from "react";
interface Props {
fallback: (props: { error: Error; reset: () => void }) => ReactNode;
onError?: (error: Error, info: { componentStack: string }) => void;
children: ReactNode;
}
interface State { error: Error | null }
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State { return { error }; }
componentDidCatch(error: Error, info: { componentStack: string }) {
this.props.onError?.(error, info);
}
render() {
if (this.state.error) {
return this.props.fallback({ error: this.state.error, reset: () => this.setState({ error: null }) });
}
return this.props.children;
}
}For most apps, react-error-boundary is the right dependency: it ships hooks, reset keys, and the boundary above with sane defaults.
Pair with React Query or SWR for data errors
React Query and SWR throw to the nearest error boundary when throwOnError is set, which is how Suspense data errors propagate. Without it, errors live on the query result and the component renders manually.
const { data } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
throwOnError: true, // hand off to the boundary
});Pick one strategy per surface. A data error that has a per-component recovery (retry button, fall back to cached) reads the result inline. A data error that takes out the whole region throws to the boundary. Mixing inline error states and a boundary on the same data is confusing; commit to one.
Design fallbacks that recover, not just apologize
A fallback UI has three jobs: tell the user what failed, offer a recovery, and avoid making the failure worse.
- Name the region that failed (“Could not load comments”), not a generic “Something went wrong.”
- Offer the recovery the user can act on: a retry button, a link to the rest of the page, or a way out of the broken flow.
- Do not embed the raw error message in production. It leaks stack traces and confuses non-technical users.
- Match the layout of the missing region so the page does not jump; see the CLS rule in core-web-vitals.
<ErrorBoundary
fallback={({ error, reset }) => (
<div role="alert" className="rounded border p-4">
<p>Could not load comments.</p>
<button onClick={reset}>Try again</button>
</div>
)}
>
<Comments postId={id} />
</ErrorBoundary>Log to Sentry with component context
Wire onError to your error reporter. Sentry’s React SDK includes a boundary, but a custom boundary works as long as it forwards the error and stack.
import * as Sentry from "@sentry/react";
function logError(error: Error, info: { componentStack: string }) {
Sentry.captureException(error, {
contexts: { react: { componentStack: info.componentStack } },
});
}Scrub PII before send: configure Sentry.init({ beforeSend }) to strip emails, tokens, and form values. Form fields entered by the user can land in breadcrumbs; see the secret-handling rule in react-forms.
Reset boundaries cleanly with resetKeys
A boundary that retries the same broken state will throw again. Use resetKeys to clear the error state when an upstream input changes; passing a new userId resets the boundary so the new fetch can run.
<ErrorBoundary resetKeys={[userId]} fallback={...}>
<UserProfile id={userId} />
</ErrorBoundary>For explicit retry, the fallback’s reset() function clears the error and re-renders the children. Combine with React Query’s queryClient.invalidateQueries so the retry hits a fresh fetch instead of a cached failure.
Place boundaries at the same seams as Suspense
A boundary too high turns a single broken widget into a broken page. A boundary too low never catches anything. Place boundaries at the same seams as Suspense fallbacks: one per independent region, nested as the layout nests.
<Suspense fallback={<PageSkeleton />}>
<Header />
<ErrorBoundary fallback={SidebarError}>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={MainError}>
<Suspense fallback={<MainSkeleton />}>
<Main />
</Suspense>
</ErrorBoundary>
</Suspense>The error boundary wraps the Suspense boundary; both speak the same boundary language. See react-suspense for the loading half and nextjs for the App Router’s error.tsx and loading.tsx convention that wires both by file.