Implementing React Error Boundaries for Production

React’s declarative rendering model creates a specific failure mode that does not exist in imperative code: a thrown exception inside a component’s render method unwinds the entire React tree and leaves the screen blank. Without an error boundary in place, a single bad prop value in a deeply nested widget can take down a checkout flow or a dashboard that has nothing to do with that widget. This guide covers every layer of a production-ready boundary strategy — from the raw class-component API through the react-error-boundary library, telemetry wiring, async bridging, and reset logic — as part of the broader Core JavaScript Error Handling & Boundaries discipline. If your application also runs non-React code, pair the patterns here with Handling Unhandled Promise Rejections in Modern JS and Mastering window.onerror and Global Event Listeners for full-spectrum coverage.

After working through this guide you will be able to:

  • Write a class-based ErrorBoundary that correctly separates state capture from side-effect logging
  • Replace boilerplate with react-error-boundary and use resetKeys to auto-recover on navigation
  • Wire componentDidCatch to an observability backend so every production failure creates an actionable alert
  • Bridge async errors into the synchronous render cycle so boundaries can catch them
  • Design fallback UI that maintains layout stability and keyboard accessibility
React Error Boundary Lifecycle Diagram showing the six stages of the React error boundary lifecycle: a child component throws during render, getDerivedStateFromError updates boundary state, componentDidCatch fires telemetry, the boundary renders fallback UI, the user triggers a reset, and the tree returns to normal render. Child throws render error getDerived StateFromError state: hasError=true componentDidCatch telemetry + stack Fallback UI render() branch Reset user / route Normal render React Error Boundary Lifecycle ① throw ② sync state ③ side-effects ④ boundary renders ⑤ reset() ⑥ recovered

Problem Framing & Symptom Identification

React renders components synchronously during a commit phase. When any component in the tree throws an uncaught exception during rendering, React propagates that exception up the fiber tree. Without a boundary to catch it, React unmounts the entire root and the DOM goes blank — or, in development mode, React overlays an error dialog that disappears in production builds, hiding the failure entirely from the user.

The symptoms that indicate you need an error boundary strategy:

  • A JavaScript exception in a third-party widget causes the whole page to go white with no visible error in the browser console for end users
  • Sentry or Datadog shows uncaught render errors but the componentStack is missing, making triage impossible
  • A failed network response that propagates as a thrown error during render takes down unrelated UI panels
  • After deploying a new release, user sessions end abruptly with no recovery path; users must do a full page reload

Error boundaries do not catch every category of JavaScript error. They are specifically designed for errors thrown during rendering, in lifecycle methods, and in constructors of class components. Errors thrown inside event handlers, setTimeout, Promise chains, or server-side rendering require different handling patterns — the async bridging section below covers how to route those into a boundary.

Prerequisites & Environment Setup

React version: Error boundaries require React 16.2 or later. The react-error-boundary library requires React 16.13+ for full resetKeys support.

TypeScript: If your project uses TypeScript, @types/react includes the ErrorInfo type used in componentDidCatch.

Install the recommended library:

npm install react-error-boundary@^4.0
# or
yarn add react-error-boundary@^4.0

If you are also setting up source map upload so that stack traces are readable in production, see Configuring Webpack for Production Source Maps before wiring telemetry — unreadable minified stacks make the telemetry step below nearly useless.

Your observability SDK (Sentry, Datadog, or a custom endpoint) should already be initialized before any React components mount. Initialize it in your application entry point, before ReactDOM.createRoot.

Step-by-Step Implementation

1. Write the basic class-based ErrorBoundary

React’s error boundary API is class-only. You need two lifecycle methods: getDerivedStateFromError (static, runs synchronously, must return state) and componentDidCatch (instance, runs after the commit, appropriate for side effects).

// src/components/ErrorBoundary.jsx
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    // hasError gates the render branch; error is passed to the fallback
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // React calls this synchronously during the render phase.
    // Return new state — do NOT produce side effects here.
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    // info.componentStack is the React fiber stack, not the JS call stack.
    // Side effects (logging, metrics) belong here, not in getDerivedStateFromError.
    console.error('[ErrorBoundary]', error, info.componentStack);
  }

  handleReset = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      // Render the fallback, passing reset so the UI can offer a retry button
      return (
        <FallbackUI
          error={this.state.error}
          onReset={this.handleReset}
        />
      );
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

Wrap any subtree you want to isolate:

<ErrorBoundary>
  <PaymentWidget />
</ErrorBoundary>

Place boundaries at logical UI seams — dashboard widgets, route-level components, third-party embeds — rather than at the app root. A single root boundary means a broken header takes down the checkout flow.

2. Replace boilerplate with react-error-boundary

The react-error-boundary package provides an ErrorBoundary component with props for FallbackComponent, onError, onReset, and resetKeys. resetKeys is the critical production feature: when any value in the array changes, the library automatically resets the boundary without user interaction — essential for route-based recovery.

// src/App.jsx
import { ErrorBoundary } from 'react-error-boundary';
import { useLocation } from 'react-router-dom';
import FallbackUI from './components/FallbackUI';
import { reportError } from './lib/telemetry';

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

  return (
    // resetKeys: boundary auto-resets when the user navigates to a new route
    <ErrorBoundary
      FallbackComponent={FallbackUI}
      onError={(error, info) => reportError(error, info)}
      resetKeys={[location.pathname]}
    >
      <Routes />
    </ErrorBoundary>
  );
}

For boundaries inside data-fetching components, the library also exports useErrorBoundary:

// Functional component that can trigger the nearest ErrorBoundary
import { useErrorBoundary } from 'react-error-boundary';

function DataWidget({ id }) {
  const { showBoundary } = useErrorBoundary();

  async function load() {
    try {
      const data = await fetchWidget(id);
      setData(data);
    } catch (err) {
      // Routes the async error into the React boundary lifecycle
      showBoundary(err);
    }
  }

  // ...
}

3. Wire componentDidCatch to an observability backend

Raw console.error calls are not useful in production. Wire onError (or componentDidCatch in the class version) to your error tracking service with enough context that an alert is actionable without a reproduction.

// src/lib/telemetry.js
export function reportError(error, errorInfo) {
  const payload = {
    message: error.message,
    stack: error.stack,
    // componentStack is the React fiber tree, not the JS call stack
    componentStack: errorInfo?.componentStack ?? null,
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: new Date().toISOString(),
    // Include any user/session context your app tracks
    sessionId: window.__SESSION_ID__ ?? 'unknown',
  };

  // Use sendBeacon so the request survives page unloads
  if (navigator.sendBeacon) {
    navigator.sendBeacon(
      '/api/errors',
      new Blob([JSON.stringify(payload)], { type: 'application/json' })
    );
  } else {
    // Fallback for environments without sendBeacon
    fetch('/api/errors', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
      keepalive: true,
    }).catch(() => {
      // Swallow fetch errors — telemetry must never cause secondary failures
    });
  }
}

If you use Sentry, replace the manual payload with Sentry.captureException(error, { contexts: { react: { componentStack: errorInfo.componentStack } } }).

4. Bridge async errors into boundaries

Errors thrown inside useEffect, event handlers, or Promise chains are invisible to error boundaries by default. The canonical bridge pattern is to store the error in state during an async path, then throw it synchronously during the next render so the boundary can intercept it.

// src/hooks/useAsyncBoundary.js
import { useState, useCallback } from 'react';

/**
 * Returns a function you can call in async code.
 * The error is stored in state, then re-thrown during render
 * so the nearest ErrorBoundary can catch it.
 */
export function useAsyncBoundary() {
  const [, setError] = useState(null);

  return useCallback((error) => {
    setError(() => {
      // Throwing inside a state updater function causes React
      // to propagate the error through the boundary mechanism.
      throw error;
    });
  }, []);
}

Usage in a component:

import { useAsyncBoundary } from '../hooks/useAsyncBoundary';

function OrderSummary({ orderId }) {
  const throwToBoundary = useAsyncBoundary();
  const [order, setOrder] = useState(null);

  useEffect(() => {
    fetchOrder(orderId)
      .then(setOrder)
      .catch(throwToBoundary); // async error now surfaces to the boundary
  }, [orderId, throwToBoundary]);

  if (!order) return <Skeleton />;
  return <OrderDetails order={order} />;
}

5. Build the fallback UI component

The fallback must preserve layout stability so surrounding page chrome (navigation, footer) remains usable. Avoid full-page takeovers for widget-level boundaries.

// src/components/FallbackUI.jsx
export default function FallbackUI({ error, resetErrorBoundary }) {
  return (
    <div
      role="alert"
      aria-live="assertive"
      style={{
        padding: '1.5rem',
        border: '1px solid #f0de93',
        borderRadius: '6px',
        background: '#fffdf2',
      }}
    >
      <h2 style={{ margin: '0 0 0.5rem', fontSize: '1rem', color: '#222018' }}>
        Something went wrong
      </h2>
      {/* Only show the message in development; never expose raw stack traces to users */}
      {process.env.NODE_ENV === 'development' && (
        <pre style={{ fontSize: '0.75rem', color: '#c0392b', whiteSpace: 'pre-wrap' }}>
          {error?.message}
        </pre>
      )}
      <button
        onClick={resetErrorBoundary}
        style={{ marginTop: '1rem' }}
      >
        Try again
      </button>
    </div>
  );
}

For guidance on progressive disclosure of error details, accessible ARIA patterns, and skeleton-based degradation strategies, see How to Gracefully Degrade UI on Component Failure.

Production Telemetry Integration

Beyond the basic reportError function in step 3, a production telemetry setup needs three additional properties to be useful:

React component stack vs. JS call stack. The info.componentStack argument to componentDidCatch is a React-specific fiber tree trace — it shows which component hierarchy led to the crash, but it does not contain file paths or line numbers from your source. The JS error.stack contains those, but in a minified build they point to mangled names and bundle line numbers. You need both in your payload, and you need source maps uploaded to your observability platform to make the JS stack readable.

Release tagging. Include the current deployment release hash in every error payload. Most CI/CD systems expose this as an environment variable (VITE_RELEASE, NEXT_PUBLIC_RELEASE, etc.). This lets you filter errors by release and correlate a spike with a specific deploy.

Sampling. In high-traffic applications, boundary fires on the same error variant will flood your error tracker. Apply client-side sampling: record the error fingerprint in sessionStorage and skip reporting if the same fingerprint was reported in the last five minutes. Always report the first occurrence.

// Prevent flooding: skip duplicate reports within the same session
const REPORT_COOLDOWN_MS = 5 * 60 * 1000;

export function reportError(error, errorInfo) {
  const key = `eb:${error.message}`;
  const last = parseInt(sessionStorage.getItem(key) || '0', 10);
  if (Date.now() - last < REPORT_COOLDOWN_MS) return;
  sessionStorage.setItem(key, String(Date.now()));
  // ... send payload
}

Verification & Testing

Unit testing with React Testing Library.

Suppress React’s default console.error output during boundary tests — React always logs caught errors in development mode, and the output clogs test reporters.

import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from 'react-error-boundary';

function Bomb() {
  throw new Error('test explosion');
}

test('renders fallback on error', () => {
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

  render(
    <ErrorBoundary FallbackComponent={({ error }) => <p>Error: {error.message}</p>}>
      <Bomb />
    </ErrorBoundary>
  );

  expect(screen.getByText('Error: test explosion')).toBeInTheDocument();
  spy.mockRestore();
});

Testing reset behavior. Render the boundary in error state, fire a user event on the retry button, then assert the boundary re-renders children (after ensuring the child no longer throws in the next render cycle, typically via a mocked flag).

End-to-end testing. In Playwright or Cypress, deliberately trigger a component error by intercepting an API call and returning a malformed response, then assert that: the fallback UI is visible, the surrounding navigation is still interactive, and a request was made to your telemetry endpoint.

Verify in production-like builds. Always run at least one test against a production build (npm run build && npm run preview) before shipping boundary changes. The React production build behaves differently from development mode — in particular, it does not re-throw boundary-caught errors to the console, which can mask a mis-configured boundary that silently swallows errors.

Failure Modes & Edge Cases

Scenario Root Cause Fix
Event handler errors not caught Boundaries only intercept render-phase errors; onClick runs outside React’s render cycle Wrap handler bodies in try/catch; call showBoundary or the async bridge hook
Async errors not caught Promise rejections and setTimeout callbacks run outside the synchronous render tree Use the useAsyncBoundary hook pattern or react-error-boundary’s useErrorBoundary
Boundary doesn’t catch its own render error A boundary cannot catch errors thrown in its own render method Place a second boundary above; keep boundary render logic trivial — no data fetching
Production stack trace unreadable Minified JS without source map upload Upload .map files to your observability platform at deploy time; see the source map cluster
Infinite reset loop resetKeys value changes on every render (e.g., a new object reference) Stabilize resetKeys values with useMemo or primitive values like location.pathname
Hydration mismatch triggers boundary Server and client render different HTML; React 18 treats some mismatches as errors Audit SSR data serialization; use suppressHydrationWarning only for known-safe differences (timestamps, ads)

FAQ

Can I write a functional component as an error boundary? No. React’s error boundary contract requires getDerivedStateFromError and componentDidCatch, which are class lifecycle methods. There is no functional equivalent in React itself. Use react-error-boundary’s ErrorBoundary component — it wraps the class internals and exposes a clean function-component API with hooks like useErrorBoundary for triggering boundaries from within functional trees.

How do I test that my boundary actually fires in CI? Write a component that unconditionally throws (function Bomb() { throw new Error('test'); }), wrap it in your boundary, render with @testing-library/react, and assert the fallback appears. Suppress console.error in the test to avoid noise. For the telemetry side, pass a mock onError callback and assert it was called with the expected error. Run these tests against both development and production builds — the boundary behavior is slightly different in each.

Do error boundaries catch React hydration mismatches? Partially. React 18 introduced “recoverable hydration errors” — minor mismatches React can fix without throwing. Severe mismatches (different element types between server and client) do throw and will be caught by a boundary, but by that point the server-rendered HTML has already been discarded. The correct fix is to eliminate the mismatch at the source, not to rely on boundary recovery.

If a Promise rejection happens inside useEffect, will the boundary catch it? Not automatically. useEffect callbacks run asynchronously after the commit and outside React’s error propagation path. Use the useAsyncBoundary hook from step 4 above: call throwToBoundary(err) in your .catch() handler, which schedules a state update that throws during the next render cycle and triggers the nearest boundary.