Skip to content
C Codeloom
React

React Error Boundaries: Catching Render Errors

Build a class-based React error boundary, understand getDerivedStateFromError and componentDidCatch, design fallback UI, and learn why async errors need different handling.

·6 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • What an error boundary actually catches
  • How to write a class-based boundary
  • The roles of getDerivedStateFromError and componentDidCatch
  • Designing useful fallback UI and recovery flows
  • Why async errors bypass boundaries and how to handle them

Prerequisites

An uncaught error during render will unmount your entire React tree. The screen goes blank and the user sees nothing useful. Error boundaries are React’s mechanism for catching those errors and showing a fallback. They are simple to write, surprisingly limited in scope, and easy to misuse. This article covers what they do, what they do not, and how to integrate them well.

What an Error Boundary Catches

An error boundary catches errors thrown during:

  • Rendering of any descendant component.
  • Lifecycle methods of descendants.
  • Constructors of descendants.

It does not catch errors in:

  • Event handlers.
  • Asynchronous code (setTimeout, promises, fetch).
  • Server-side rendering (in older React versions; behavior has improved with newer streaming SSR).
  • Errors thrown by the boundary itself.

In other words, error boundaries handle errors that happen during React’s own work. Errors that happen outside of React’s control need their own handling, usually a try/catch.

The Minimal Class Boundary

As of React 19, error boundaries are still only available as class components. There is no hook equivalent yet. Here is the canonical shape.

import { Component, type ReactNode, type ErrorInfo } from 'react';

type Props = { fallback: ReactNode; children: ReactNode };
type State = { hasError: boolean };

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

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

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error('Caught by boundary:', error, info.componentStack);
  }

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

Two static and lifecycle methods carry the weight.

getDerivedStateFromError

This is called during the render phase. Because it is static, it cannot do side effects. Its only job is to return the next state, signaling that the boundary should show its fallback. Keep it pure.

componentDidCatch

This runs during the commit phase, after the error is caught. It is the right place for side effects: logging to a service like Sentry, sending analytics, or persisting context for a bug report. It also receives a componentStack string that helps locate where the failure happened in the component tree.

Using a Boundary

Wrap any subtree that you want to isolate from a failure elsewhere.

function App() {
  return (
    <ErrorBoundary fallback={<p>Something went wrong.</p>}>
      <Dashboard />
    </ErrorBoundary>
  );
}

Errors thrown inside Dashboard (or anywhere below it) during render will bubble up, hit the boundary, and trigger the fallback. The rest of the app keeps running. For a more focused boundary, wrap the smallest area that makes sense.

<Layout>
  <Sidebar />
  <ErrorBoundary fallback={<WidgetFallback />}>
    <RiskyWidget />
  </ErrorBoundary>
  <Footer />
</Layout>

A failure in RiskyWidget swaps it for WidgetFallback, but Sidebar and Footer keep rendering normally. That granularity is one of the main reasons boundaries exist.

Designing the Fallback

A fallback should do three things: communicate that something failed, suggest a recovery path, and avoid making the situation worse.

function WidgetFallback({ onRetry }: { onRetry: () => void }) {
  return (
    <div role="alert" className="p-4 border rounded">
      <h2>This widget could not be loaded.</h2>
      <p>The rest of the page is still usable.</p>
      <button onClick={onRetry}>Try again</button>
    </div>
  );
}

Notice the role="alert" for screen readers and the explicit retry. The boundary itself does not know how to retry; you have to give it a way to reset its state. Here is a version that exposes a reset.

type Props = {
  fallback: (props: { reset: () => void }) => ReactNode;
  children: ReactNode;
};

class ResettableBoundary extends Component<Props, { hasError: boolean }> {
  state = { hasError: false };

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

  reset = () => this.setState({ hasError: false });

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

Now the consumer can wire a retry button to clear the error state.

<ResettableBoundary
  fallback={({ reset }) => <WidgetFallback onRetry={reset} />}
>
  <RiskyWidget />
</ResettableBoundary>

If the underlying problem persists, the next render will throw again and the boundary catches it again. That is the desired loop.

Async Errors: The Big Caveat

This is where most teams get tripped up. Consider this handler.

function SaveButton() {
  const onClick = async () => {
    const res = await fetch('/api/save', { method: 'POST' });
    if (!res.ok) throw new Error('Save failed'); // not caught by a boundary
  };
  return <button onClick={onClick}>Save</button>;
}

The error throws inside an event handler, and inside a promise. Neither is caught by an error boundary. You will see an unhandled rejection in the console while your boundary stays inert.

Handle these errors explicitly.

const [error, setError] = useState<Error | null>(null);

const onClick = async () => {
  try {
    const res = await fetch('/api/save', { method: 'POST' });
    if (!res.ok) throw new Error('Save failed');
  } catch (err) {
    setError(err as Error);
  }
};

If you want errors caught by an event handler to be displayed through a boundary, you can re-throw them during render.

if (error) throw error;

That triggers the nearest boundary and reuses your fallback design. Some teams build a small useErrorHandler hook that does this for them.

For data-loading patterns, libraries like React Query bring their own error states; you typically render the error directly rather than relying on a boundary. If you are using React Router’s data APIs, errors from loaders and actions go through the route’s errorElement, not through component boundaries.

Where to Place Boundaries

A common pattern: a top-level boundary that catches anything missed, and finer-grained boundaries around features that can fail independently.

<ErrorBoundary fallback={<GlobalErrorPage />}>
  <Layout>
    <ErrorBoundary fallback={<FeedFallback />}>
      <Feed />
    </ErrorBoundary>
    <ErrorBoundary fallback={<SidebarFallback />}>
      <Sidebar />
    </ErrorBoundary>
  </Layout>
</ErrorBoundary>

If Feed blows up, Sidebar stays usable. If something in Layout itself crashes, the global fallback takes over. The fact that boundaries are themselves components means you compose them where you need them, without globally configuring anything.

Production Logging

Inside componentDidCatch, ship the error to your monitoring tool of choice with enough context to debug.

componentDidCatch(error: Error, info: ErrorInfo) {
  reportToSentry(error, { componentStack: info.componentStack });
}

Pair this with source maps in production so the stack traces are usable. Without source maps, you will see minified names and have a hard time tracing back.

Wrap up

Error boundaries are React’s safety net for render-time failures. Use a class component, implement getDerivedStateFromError for state and componentDidCatch for logging, and give your fallback a recovery path. Remember the boundary’s blind spots, especially async code and event handlers, and combine boundaries with explicit try/catch in those places. Used well, they turn a blank page into a graceful degradation.