This page is part of the Framework-Specific Crash Recovery & Error Handlers section, which covers the implementation patterns that translate abstract boundary theory into per-framework production code.

The Problem This Solves #

A third-party analytics widget throws a TypeError during its render cycle. Without containment, React’s default behaviour is to unmount the entire component tree, leaving the user with a blank screen and no recovery path. The blast radius extends from one broken dependency to every UI element the user was interacting with — including form inputs, shopping carts, and navigation.

React error boundaries stop that cascade. They catch render-phase exceptions within a defined subtree, swap in a fallback UI, and leave all sibling and ancestor components running. The failure mode moves from “total blank screen” to “one section shows a recovery prompt”.

Prerequisites #

Core Implementation #

The boundary requires two lifecycle hooks: getDerivedStateFromError runs synchronously during the failed render to set state before the next paint, and componentDidCatch runs asynchronously after React has unwound the tree to let you dispatch telemetry without blocking recovery.

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

// --- Interfaces ---

interface ErrorBoundaryProps {
  /** Static node or render-prop accepting the caught Error and a reset callback. */
  fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
  /** Optional telemetry callback — called inside componentDidCatch. */
  onError?: (error: Error, info: ErrorInfo) => void;
  /** React key that, when changed externally, triggers an automatic reset. */
  resetKey?: string | number;
  children: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

// --- Boundary class ---

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

  /**
   * getDerivedStateFromError is a STATIC method so React can call it
   * without a this reference during the synchronous error unwinding phase.
   * Returning new state here is the ONLY way to display a fallback on first paint.
   */
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  /**
   * componentDidCatch fires after the fallback has rendered.
   * Safe for async side-effects: telemetry, logging, analytics.
   * errorInfo.componentStack traces the React tree to the failing element.
   */
  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    this.props.onError?.(error, errorInfo);
  }

  /**
   * When resetKey changes (e.g. after a route change), clear error state.
   * This prevents a recovered page from showing a stale fallback.
   */
  componentDidUpdate(prevProps: ErrorBoundaryProps): void {
    if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
      this.setState({ hasError: false, error: null });
    }
  }

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

  render(): ReactNode {
    if (this.state.hasError && this.state.error) {
      const { fallback } = this.props;
      return typeof fallback === 'function'
        ? fallback(this.state.error, this.resetBoundary)
        : fallback;
    }
    return this.props.children;
  }
}

Architecture: Why Class Components and Two Lifecycle Hooks #

React’s reconciliation engine needs a synchronous way to know whether to substitute a fallback tree before the browser paints. getDerivedStateFromError satisfies that contract — it runs during the render phase, before any commit, and its return value is merged into state before the next render begins.

componentDidCatch intentionally runs after the commit phase. This is not a limitation; it is a guarantee that telemetry code cannot throw and break the recovery render. The separation means you have a clean, deterministic two-step model:

  1. Render phasegetDerivedStateFromError updates state, React re-renders with the fallback tree.
  2. Commit phasecomponentDidCatch fires, you dispatch non-blocking telemetry.

Functional components cannot implement either hook today because hooks run inside the render function and have no equivalent to static class methods that the reconciler can call without a component instance.

Edge Cases and Failure Mode Map #

React Error Boundary Catch Scope A flowchart illustrating that render-phase errors, constructor errors, and lifecycle method errors are caught by error boundaries, while event handler errors, async errors, and server-side rendering errors require manual try/catch or promise rejection handling. Error thrown in React app During render / lifecycle / constructor? YES Boundary catches it getDerivedState FromError() Fallback UI rendered NO Boundary does NOT catch Event handlers → try/catch Async (Promises) → rejection handler SSR → getServerSideProps componentDidCatch() dispatches telemetry
Failure mode Caught by boundary? Mitigation
Render-phase TypeError Yes Boundary handles automatically
Constructor throw Yes Boundary handles automatically
componentDidMount throw Yes Boundary handles automatically
onClick / onChange throw No Wrap handler body in try/catch; call setState with the error
fetch rejection No .catch() chain or try/catch in async effect
setTimeout / setInterval No Internal try/catch; route error to state
SSR (getServerSideProps) No Handle at the data-fetching layer; return error props
Errors in the boundary itself No Errors in render() of the boundary propagate upward to the next boundary
Infinite fallback re-render Guard getDerivedStateFromError with a stable key comparison

Advanced Variant: Session Preservation Before Fallback #

When an error boundary fires, React unmounts the affected subtree — any component state held in memory is lost. Intercepting this requires persisting critical data before the unmount. The hook below writes debounced snapshots to sessionStorage; the fallback component reads and offers to restore them. This pairs with the Draft Auto-Save Recovery Workflows pattern for long-form editors.

import React, { useEffect, useCallback, useRef, FC, ReactNode } from 'react';

// --- Debounced persistence hook ---

export function useDebouncedStatePersistence<T>(
  key: string,
  value: T,
  delayMs = 300
): void {
  useEffect(() => {
    const timer = setTimeout(() => {
      try {
        sessionStorage.setItem(key, JSON.stringify(value));
      } catch {
        // Quota exceeded or serialization error: fail silently, don't lose the session
      }
    }, delayMs);
    return () => clearTimeout(timer);
  }, [key, value, delayMs]);
}

// --- Fallback UI with session restore ---

interface RecoveryFallbackProps {
  error: Error;
  storageKey: string;
  onRestore: (data: unknown) => void;
  onReset: () => void;
}

export const RecoveryFallback: FC<RecoveryFallbackProps> = ({
  error,
  storageKey,
  onRestore,
  onReset,
}) => {
  const hasSaved = !!sessionStorage.getItem(storageKey);

  const handleRestore = useCallback(() => {
    const raw = sessionStorage.getItem(storageKey);
    if (!raw) return;
    try {
      onRestore(JSON.parse(raw));
      onReset();
    } catch {
      // Corrupted payload: reset without restoring
      onReset();
    }
  }, [storageKey, onRestore, onReset]);

  return (
    <div role="alert" aria-live="polite" aria-atomic="true">
      <p>An unexpected error occurred in this section.</p>
      <p><code>{error.message}</code></p>
      {hasSaved ? (
        <button type="button" onClick={handleRestore}>
          Restore saved progress and retry
        </button>
      ) : (
        <button type="button" onClick={onReset}>
          Retry
        </button>
      )}
    </div>
  );
};

Key guards:

  • Quota exceeded — catch the sessionStorage.setItem call; a full storage quota must not prevent the debounce from returning cleanly.
  • Stale closures on unmount — read from sessionStorage at restore time, not from a captured closure; the closure may reference data that was already unmounted.
  • PII allowlisting — serialize only the fields you explicitly include; never pass raw form objects to JSON.stringify.

See LocalStorage and IndexedDB Sync Strategies for durable persistence across full page reloads.

Telemetry Integration #

Dispatching error reports from componentDidCatch must not block the UI thread or introduce secondary crashes. Use navigator.sendBeacon for guaranteed delivery on page unload, with a fetch+keepalive fallback. Rate-limit to avoid flooding your ingestion pipeline during cascading failures.

import { useEffect, useRef } from 'react';
import type { ErrorInfo } from 'react';

interface TelemetryConfig {
  endpoint: string;
  /** Minimum milliseconds between successive reports for the same boundary. */
  rateLimitMs: number;
  /** Strip PII and framework internals before transmission. */
  sanitize: (
    error: Error,
    componentStack?: string
  ) => Record<string, unknown>;
}

export function useErrorTelemetry(config: TelemetryConfig) {
  const lastReportRef = useRef<number>(0);
  const { endpoint, rateLimitMs, sanitize } = config;

  const report = useCallback(
    (error: Error, errorInfo?: ErrorInfo): void => {
      const now = Date.now();
      if (now - lastReportRef.current < rateLimitMs) return;
      lastReportRef.current = now;

      let payload: Record<string, unknown>;
      try {
        payload = sanitize(error, errorInfo?.componentStack);
      } catch {
        // Sanitizer itself failed — send a minimal safe payload
        payload = { message: 'sanitizer_error', ts: now };
      }

      const body = JSON.stringify(payload);
      if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
        navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }));
      } else {
        fetch(endpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body,
          keepalive: true,
        }).catch(() => {
          // Network failure during reporting must never re-throw into the recovery path
        });
      }
    },
    [endpoint, rateLimitMs, sanitize]
  );

  return report;
}

Wire report into the boundary’s onError prop. The onError callback receives the same (error, errorInfo) pair that componentDidCatch provides. For Next.js and Nuxt routing error pages, respect the client/server boundary: avoid double-reporting errors that Next.js already catches at the framework layer.

Testing and CI/CD Validation #

Verify boundary behaviour with a ThrowOnMount helper that throws deterministically, suppressed console.error mocking, and explicit assertions on both the fallback render and the post-reset recovery render. This pattern integrates cleanly with Vitest or Jest without relying on unstable React internals.

import { render, screen, fireEvent } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ErrorBoundary } from './ErrorBoundary';

/** Deterministic error injector — throws on first render only. */
const ThrowOnMount = ({ shouldThrow }: { shouldThrow: boolean }) => {
  if (shouldThrow) throw new Error('Injected render error');
  return <div>Recovered content</div>;
};

describe('ErrorBoundary', () => {
  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    // Suppress React's built-in console.error output for uncaught boundary errors
    consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
  });

  afterEach(() => {
    consoleErrorSpy.mockRestore();
  });

  it('renders the fallback when a child throws during render', () => {
    render(
      <ErrorBoundary fallback={<p>Fallback shown</p>}>
        <ThrowOnMount shouldThrow />
      </ErrorBoundary>
    );
    expect(screen.getByText('Fallback shown')).toBeTruthy();
  });

  it('calls onError with the caught error and component stack', () => {
    const onError = vi.fn();
    render(
      <ErrorBoundary fallback={<p>Error</p>} onError={onError}>
        <ThrowOnMount shouldThrow />
      </ErrorBoundary>
    );
    expect(onError).toHaveBeenCalledOnce();
    expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
    expect(onError.mock.calls[0][1].componentStack).toContain('ThrowOnMount');
  });

  it('recovers and renders children after reset is called', () => {
    const onError = vi.fn();
    render(
      <ErrorBoundary
        fallback={(_, reset) => (
          <button onClick={reset}>Reset boundary</button>
        )}
        onError={onError}
      >
        <ThrowOnMount shouldThrow={false} />
      </ErrorBoundary>
    );
    expect(screen.getByText('Recovered content')).toBeTruthy();
  });
});

For end-to-end validation in CI, inject errors via page.evaluate(() => { throw new Error('e2e inject'); }) in Playwright and assert the fallback selector appears within the configured timeout. Add a window.onerror listener in test setup to catch any uncaught exceptions that bypass the boundary.

Focused Deep-Dives #

The patterns above give you a working production boundary, but several common scenarios require more targeted treatment. Building Reusable React Error Boundary Components with TypeScript covers generic wrapper patterns, render props, and strict error type narrowing for large codebases where a single boundary shape cannot cover every feature domain.

For async error management — which boundaries explicitly do not handle — see Custom Hooks for Async Error Catching, which documents useAsyncError, promise rejection bridges, and the useErrorBoundary escape-hatch pattern that lets hooks route async failures into the nearest boundary.

Frequently Asked Questions #

Can React error boundaries catch errors inside event handlers or async code? No. Error boundaries only intercept errors thrown synchronously during the render phase, in lifecycle methods, and in constructors. Event handlers and async operations execute outside the React reconciliation cycle and must be handled with explicit try/catch blocks or promise rejection chains that update component state.

How do I preserve user session state when an error boundary triggers? Serialize critical state to sessionStorage or IndexedDB in a debounced effect before the crash occurs. On reset, read that stored data and reinitialize component state so users can resume without data loss. See LocalStorage and IndexedDB Sync Strategies for a durable implementation.

Should I wrap my entire application in a single error boundary? No. Use multiple granular boundaries to isolate failures. A global boundary should serve only as a last resort, while feature-level boundaries enable partial recovery, better UX, and more precise telemetry attribution — which maps directly to the scoping principles in Error Propagation Strategies.

How do I test error boundaries without causing flaky CI pipelines? Use a deterministic ThrowOnMount helper and suppress console.error with a mock before each test. Assert the fallback renders, then assert children render correctly after calling resetBoundary. Avoid relying on unstable_createRoot APIs or global error event listeners in unit tests — those introduce ordering dependencies between test files.