---
title: "React: Error Boundaries"
slug: "react-error-boundaries"
category: "frontend"
tags: ["frontend", "react", "errors", "resilience", "observability"]
status: "stable"
last_updated: 2026-05-14
summary: "Error boundaries catch render-tree errors only; pair with React Query or SWR for data errors; design fallback UI; log to Sentry; reset cleanly."
related: ["[[frontend/react]]", "[[frontend/react-suspense]]", "[[frontend/react-server-components]]", "[[frontend/nextjs]]", "[[frontend/react-forms]]", "[[coding/typescript]]"]
---

## 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`). Use `try/catch` inline.
- Errors thrown in async callbacks (`setTimeout`, `fetch().then`). Use `try/catch` or a promise `.catch`.
- Errors thrown during server-side rendering. The framework handles those through `error.tsx` in the Next.js App Router.
- 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.

```tsx
"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](https://github.com/bvaughn/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.

```tsx
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 [[seo/core-web-vitals]].

```tsx
<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.

```ts
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 [[frontend/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.

```tsx
<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.

```tsx
<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 [[frontend/react-suspense]] for the loading half and [[frontend/nextjs]] for the App Router's `error.tsx` and `loading.tsx` convention that wires both by file.

## Related

- [[frontend/react]]
- [[frontend/react-suspense]]
- [[frontend/react-server-components]]
- [[frontend/nextjs]]
- [[frontend/react-forms]]
- [[frontend/react-state-management]]
- [[coding/typescript]]
