Type something to search...
Error Boundaries: Gracefully Handling Crashes in React

Error Boundaries: Gracefully Handling Crashes in React

When a React component throws during rendering and nothing catches it, React unmounts the entire tree. Your users don't see a helpful message. They see a white page. One bad API response with a missing field, one undefined.map, and the whole app is gone, including the navigation they could have used to go somewhere else.

Error boundaries are React's answer. They're components that catch errors thrown while rendering their children, show a fallback UI instead, and leave the rest of the app running. Used well, a broken chart becomes a small "This chart couldn't load" card, while the dashboard around it keeps working.

In this post you'll write an error boundary from scratch, learn exactly which errors it catches and which it doesn't, switch to the react-error-boundary library for resets and hooks, handle errors from event handlers and async code, decide where boundaries belong, and report errors to a logging service using React 19's root-level error callbacks.

What Happens Without an Error Boundary

Since React 16, an uncaught render error removes the whole React tree from the page. That sounds harsh, but the reasoning is sound: leaving a half-broken UI on screen can be worse than removing it. A banking app showing the wrong balance because one component failed to update is more dangerous than an error page.

Here's a component that crashes when data is missing:

type Order = { id: string; items?: { name: string }[] };

export function OrderItems({ order }: { order: Order }) {
  // Throws "Cannot read properties of undefined (reading 'map')"
  return (
    <ul>
      {order.items!.map((item) => (
        <li key={item.name}>{item.name}</li>
      ))}
    </ul>
  );
}

Without a boundary, rendering this with an order that has no items takes down everything. With a boundary around it, only this list is replaced.

Writing an Error Boundary

Error boundaries must be class components. There's still no hook equivalent, because the hooks API has no way to catch errors from children during render. A boundary implements one or both of these lifecycle methods:

  • static getDerivedStateFromError(error) returns new state so the next render shows the fallback.
  • componentDidCatch(error, info) runs after the error is caught, for side effects like logging. info.componentStack tells you which components were rendering.
// src/components/error-boundary.tsx
import { Component, type ErrorInfo, type ReactNode } from "react";

type Props = {
  fallback: ReactNode;
  children: ReactNode;
  onError?: (error: Error, info: ErrorInfo) => void;
};

type State = { hasError: boolean };

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

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(error, info);
  }

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

Use it like any wrapper:

<ErrorBoundary fallback={<p>We couldn't show your order items.</p>}>
  <OrderItems order={order} />
</ErrorBoundary>

When OrderItems throws, React finds the nearest boundary above it, calls getDerivedStateFromError, and re-renders the boundary with hasError: true. Everything outside the boundary is untouched.

You write this class once and use it everywhere. The rest of your code stays function components.

What Error Boundaries Catch, and What They Don't

Error boundaries catch errors thrown during:

  • Rendering of any component below them.
  • Lifecycle methods and effects (useEffect, useLayoutEffect) of components below them.
  • Constructors of class components below them.
  • A rejected promise read with use inside a Suspense boundary below them.

They don't catch errors from:

  • Event handlers. A throw inside onClick happens outside rendering, so React doesn't know to show a fallback. Use try/catch in the handler.
  • Async code like setTimeout callbacks or a fetch that rejects inside an effect after the effect function has returned.
  • The boundary itself. If the boundary's own render throws, the error goes to the next boundary up.
  • Server rendering in the classic sense. Frameworks have their own error handling for that.

The event handler case surprises people the most:

function DeleteButton({ id }: { id: string }) {
  async function handleClick() {
    // This error is NOT caught by an error boundary
    const res = await fetch(`/api/items/${id}`, { method: "DELETE" });
    if (!res.ok) throw new Error("Delete failed");
  }
  return <button onClick={handleClick}>Delete</button>;
}

The thrown error becomes an unhandled promise rejection in the console, and the UI just sits there. You'll see how to route these errors into a boundary shortly.

Using react-error-boundary

Writing the class is easy, but production apps usually need more: passing the error to the fallback, a "Try again" button, resetting when some value changes, and a way to trigger the boundary from async code. The react-error-boundary package covers all of that and is the de facto standard.

npm install react-error-boundary

Fallback Components With Reset

// src/components/widget-error.tsx
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
import { RevenueChart } from "./revenue-chart";

function WidgetFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div role="alert" className="widget-error">
      <p>This widget failed to load.</p>
      <pre className="error-detail">
        {error instanceof Error ? error.message : String(error)}
      </pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

export function RevenueWidget() {
  return (
    <ErrorBoundary
      FallbackComponent={WidgetFallback}
      onError={(error, info) => console.error(error, info.componentStack)}
      onReset={() => {
        // Clear any cached data that caused the error before re-rendering
      }}
    >
      <RevenueChart />
    </ErrorBoundary>
  );
}

resetErrorBoundary clears the error state and renders the children again. If the error came from bad cached data, use onReset to clear that cache first, or the same error happens immediately. Avoid showing raw error messages to users in production. Show them in development and log them everywhere.

Resetting When Inputs Change

Often the right moment to reset isn't a button click but a change in input, like navigating to another page or selecting a different item. Pass resetKeys, and the boundary resets automatically when any key changes:

import type { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useLocation } from "react-router";

export function PageErrorBoundary({ children }: { children: ReactNode }) {
  const location = useLocation();

  return (
    <ErrorBoundary
      fallback={<p>Something went wrong on this page.</p>}
      resetKeys={[location.pathname]}
    >
      {children}
    </ErrorBoundary>
  );
}

Without resetKeys, a boundary that wraps your routes would keep showing the error even after the user clicks a link to a different page. Another way to reset is giving the boundary a key, which remounts it, for example key={selectedUserId}.

Sending Async and Event Handler Errors to a Boundary

react-error-boundary provides the useErrorBoundary hook. Its showBoundary function takes an error and triggers the nearest boundary, even from an event handler or promise:

import { useErrorBoundary } from "react-error-boundary";

export function ExportButton({ reportId }: { reportId: string }) {
  const { showBoundary } = useErrorBoundary();

  async function handleExport() {
    try {
      const res = await fetch(`/api/reports/${reportId}/export`, { method: "POST" });
      if (!res.ok) throw new Error(`Export failed with status ${res.status}`);
    } catch (error) {
      showBoundary(error);
    }
  }

  return <button onClick={handleExport}>Export</button>;
}

Be selective about this. A failed export is usually better handled with an inline message or toast, because replacing the whole section with an error is heavy-handed for a recoverable action. Use showBoundary for errors that leave the component in a broken state, like failing to load the data it needs to render at all.

If you'd rather not use the library, the same thing works with plain state, since throwing during render is what a boundary catches:

import { useState } from "react";

function useThrowToBoundary() {
  const [error, setError] = useState<Error | null>(null);
  if (error) throw error;
  return setError;
}

Calling the returned setter with an error schedules a render, and that render throws inside the boundary.

Error Boundaries and Suspense

Data libraries that use Suspense report failures by throwing. A rejected promise read with use or a failed useSuspenseQuery goes to the nearest error boundary, so pair every Suspense with one:

import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { TodoList } from "./todo-list";

export function TodosSection() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ resetErrorBoundary }) => (
            <div role="alert">
              <p>Couldn't load todos.</p>
              <button onClick={resetErrorBoundary}>Retry</button>
            </div>
          )}
        >
          <Suspense fallback={<p>Loading todos...</p>}>
            <TodoList />
          </Suspense>
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

QueryErrorResetBoundary tells TanStack Query to refetch failed queries when the boundary resets, so "Retry" actually retries the request. The error boundary goes outside the Suspense boundary so it catches errors from both loading and rendering. For the details of how suspending and errors interact, see Suspense for data fetching under the hood.

Where to Place Error Boundaries

There's no single right answer, but a layered approach works well for most apps:

  1. A root boundary around the whole app. The last line of defense, showing a full-page "Something went wrong" with a reload button. It should be extremely simple so it can't fail itself.
  2. A route-level boundary around page content, inside the layout. The navigation stays usable, and the boundary resets on navigation. If you use React Router data mode, route ErrorBoundary exports fill this role, as shown in the React Router v7 beginner's guide.
  3. Widget-level boundaries around independent, failure-prone pieces: third-party embeds, charts, rich text renderers, anything parsing user content.
// src/app.tsx
import { ErrorBoundary } from "react-error-boundary";
import { AppRoutes } from "./routes";

function RootFallback() {
  return (
    <div role="alert" className="fatal-error">
      <h1>Something went wrong</h1>
      <p>Please reload the page. If this keeps happening, contact support.</p>
      <button onClick={() => window.location.reload()}>Reload</button>
    </div>
  );
}

export function App() {
  return (
    <ErrorBoundary FallbackComponent={RootFallback}>
      <AppRoutes />
    </ErrorBoundary>
  );
}

Don't wrap every component. Boundaries add noise, and a page full of tiny error cards is confusing. Ask: "If this part breaks, what should the user still be able to do?" Put a boundary at the edge of that answer.

Reporting Errors in React 19

Logging inside each boundary's onError works, but React 19 also offers root-level callbacks on createRoot, which give you one place to report every error:

// src/main.tsx
import { createRoot } from "react-dom/client";
import { App } from "./app";
import { reportError } from "./lib/report-error";

createRoot(document.getElementById("root")!, {
  onCaughtError(error, errorInfo) {
    // Caught by an error boundary
    reportError(error, { componentStack: errorInfo.componentStack, handled: true });
  },
  onUncaughtError(error, errorInfo) {
    // Not caught by any boundary; the tree was unmounted
    reportError(error, { componentStack: errorInfo.componentStack, handled: false });
  },
  onRecoverableError(error, errorInfo) {
    // React recovered automatically, e.g. a hydration mismatch
    reportError(error, { componentStack: errorInfo.componentStack, recoverable: true });
  },
}).render(<App />);

reportError can forward to Sentry, Datadog, or your own endpoint. React 19 also stopped logging caught errors twice in development, which makes the console far more readable than in React 18.

One more development detail: in dev mode, React still reports errors that a boundary caught in the console, and tools like Vite's error overlay may show them. That doesn't mean the boundary failed. Check the actual page behind the overlay.

Best Practices for Error Boundaries

  • Layer your boundaries. Root, route, and widget levels cover most needs.
  • Keep fallbacks simple and safe. A fallback that depends on the same broken data will crash too.
  • Always offer a way forward. A retry button, a link home, or a reload. A dead end is barely better than a white screen.
  • Reset on navigation. Use resetKeys or route-level boundaries so errors don't follow the user to other pages.
  • Handle event handler errors explicitly. Use try/catch with inline feedback, or showBoundary when the component can't continue.
  • Report every error. Use onCaughtError and onUncaughtError so you learn about crashes before users email you.
  • Don't show stack traces to users. Log them, and show a friendly message instead.

Frequently Asked Questions (FAQ) About React Error Boundaries

Not with React's built-in APIs. Error boundaries need getDerivedStateFromError or componentDidCatch, which only exist on class components. The react-error-boundary package gives you a ready-made class so the rest of your code can stay function components.

Event handlers run outside of rendering, so React doesn't route their errors to boundaries. Catch the error with try/catch and show feedback, or pass it to showBoundary from useErrorBoundary if the component can't recover.

Call resetErrorBoundary from the fallback, pass resetKeys that change when the user navigates or selects something new, or change the boundary's key to remount it. Clear any cached data that caused the error first, or it will throw again.

Yes. Errors thrown synchronously inside effects and their cleanups are caught by the nearest boundary. Errors thrown later from async code started in an effect, like a rejected fetch, are not, unless you capture them and rethrow during render or use showBoundary.

No. Place them where a failure should be contained: the app root, each page, and independent widgets. Too many boundaries make the UI fragmented and harder to reason about.

React logs caught errors in development so you notice them, and some dev tools show an overlay. The boundary is still working. Close the overlay or check the production build to see what users experience.

Conclusion

Error boundaries turn a crash in one component into a contained, recoverable problem instead of a blank page. A boundary is a small class component with getDerivedStateFromError and componentDidCatch, and it catches errors from rendering, lifecycles, and effects below it. Event handlers and async code need explicit handling, either inline or through showBoundary.

In practice, install react-error-boundary, add a simple root boundary, put route-level boundaries that reset on navigation, and wrap risky widgets individually. Pair every Suspense boundary with an error boundary, and wire up onCaughtError and onUncaughtError so every crash gets reported. Your users will still hit bugs, but they'll hit them in a small box with a retry button, not a white screen.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading