Skip to content
Codeloom
React

React Error Boundary Patterns and Recovery Strategies

Master error boundary patterns in React including granular boundaries, recovery strategies, logging, and the react-error-boundary library.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How error boundaries work in React
  • Granular boundary placement strategies
  • Recovery and retry patterns
  • Using react-error-boundary for cleaner code
  • Logging errors to external services

Prerequisites

  • Basic React knowledge
  • Familiarity with component composition

Error boundaries catch JavaScript errors during rendering, in lifecycle methods, and in constructors of the component tree below them. Without boundaries, a single error in one component crashes the entire app. With well-placed boundaries, the rest of the application keeps working while the broken section shows a fallback.

The Class-Based Error Boundary

React only supports error boundaries as class components. You need to implement static getDerivedStateFromError to update state and componentDidCatch to perform side effects like logging.

import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

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

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

Use it by wrapping any part of your tree:

<ErrorBoundary fallback={<p>This widget failed to load.</p>}>
  <WeatherWidget />
</ErrorBoundary>

What Error Boundaries Do Not Catch

Error boundaries do not catch errors in event handlers, async code (setTimeout, fetch), or server-side rendering. For event handlers, use regular try-catch blocks. For async errors, catch them where the promise is handled or use Suspense with error boundaries.

function SubmitButton() {
  const handleClick = async () => {
    try {
      await submitForm();
    } catch (err) {
      // Handle async error here, not in a boundary
      showToast('Submission failed');
    }
  };

  return <button onClick={handleClick}>Submit</button>;
}

Granular Boundary Placement

The most important decision is where to place boundaries. Too few and a single error takes down a large section. Too many and you litter your code with fallback UI that fragments the user experience.

A good rule of thumb is to place boundaries around independent features. A dashboard might have separate boundaries for the header, each widget, and the sidebar.

function Dashboard() {
  return (
    <div className="dashboard">
      <ErrorBoundary fallback={<HeaderFallback />}>
        <Header />
      </ErrorBoundary>

      <div className="widgets">
        <ErrorBoundary fallback={<WidgetFallback name="Sales" />}>
          <SalesWidget />
        </ErrorBoundary>

        <ErrorBoundary fallback={<WidgetFallback name="Users" />}>
          <UsersWidget />
        </ErrorBoundary>

        <ErrorBoundary fallback={<WidgetFallback name="Revenue" />}>
          <RevenueWidget />
        </ErrorBoundary>
      </div>

      <ErrorBoundary fallback={<SidebarFallback />}>
        <Sidebar />
      </ErrorBoundary>
    </div>
  );
}

If the SalesWidget crashes, the other widgets and the sidebar remain functional.

Recovery with Key-Based Remounting

The simplest recovery strategy is to remount the failed component. Change the key on the error boundary to force React to unmount and remount the entire subtree, which clears the error state.

function ResettableWidget() {
  const [resetKey, setResetKey] = useState(0);

  return (
    <ErrorBoundary
      key={resetKey}
      fallback={
        <div>
          <p>Widget crashed.</p>
          <button onClick={() => setResetKey(k => k + 1)}>Retry</button>
        </div>
      }
    >
      <ExpensiveWidget />
    </ErrorBoundary>
  );
}

Clicking Retry increments the key, which unmounts the boundary and its children, creating a fresh instance. This works well when the error was caused by transient state.

Using react-error-boundary

The react-error-boundary library provides a feature-rich boundary component with built-in reset, fallback render props, and error handler callbacks.

npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" className="error-panel">
      <h3>Something went wrong</h3>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => {
        logErrorToService(error, info.componentStack);
      }}
      onReset={() => {
        // Clear any stale state before retry
        queryClient.clear();
      }}
    >
      <MainContent />
    </ErrorBoundary>
  );
}

The onReset callback runs before the boundary re-renders its children, giving you a chance to clear caches or reset state that caused the error.

Resetting on Navigation

When the user navigates to a different page, you typically want to clear any error states. Pass the current route as a resetKey so the boundary resets automatically.

import { useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';

function AppShell() {
  const location = useLocation();

  return (
    <ErrorBoundary
      FallbackComponent={PageErrorFallback}
      resetKeys={[location.pathname]}
    >
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </ErrorBoundary>
  );
}

Logging Errors to External Services

In production, you need to send errors to a monitoring service like Sentry, Datadog, or LogRocket. The componentDidCatch method (or onError in react-error-boundary) is the right place.

import * as Sentry from '@sentry/react';

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => {
        Sentry.captureException(error, {
          extra: { componentStack: info.componentStack },
        });
      }}
    >
      <MainApp />
    </ErrorBoundary>
  );
}

Sentry also provides its own Sentry.ErrorBoundary component that handles this automatically.

Combining with Suspense

Error boundaries and Suspense boundaries work together. Place the error boundary outside the Suspense boundary so it catches both render errors and rejected promises from the use() hook.

<ErrorBoundary FallbackComponent={ErrorFallback}>
  <Suspense fallback={<Spinner />}>
    <DataComponent dataPromise={dataPromise} />
  </Suspense>
</ErrorBoundary>

If dataPromise rejects, the error boundary catches it. If the component throws during render, the error boundary catches that too. The Suspense boundary only handles the pending state.

Building a Reusable Boundary Component

For consistency across your app, create a configured boundary that includes your standard error UI and logging.

import { ErrorBoundary } from 'react-error-boundary';
import { logError } from '@/lib/monitoring';

export function AppErrorBoundary({ children, level = 'section' }) {
  return (
    <ErrorBoundary
      FallbackComponent={level === 'page' ? PageFallback : SectionFallback}
      onError={logError}
    >
      {children}
    </ErrorBoundary>
  );
}

Then use it consistently:

<AppErrorBoundary level="page">
  <Dashboard />
</AppErrorBoundary>

Error boundaries are a safety net. The goal is never to need them, but when something unexpected happens, they keep your app usable and give you the information you need to fix the problem.