This page is a focused companion to Error Propagation Strategies, which explains how exceptions traverse the component tree and when they must be intercepted.

The exact problem: picking the wrong scope causes more damage than the original crash #

A dashboard SPA renders twelve independent widgets. A DataParseError in the revenue chart throws during render. If the only boundary in the tree is a root-level global one, the entire application unmounts β€” the active multi-step form in the sidebar loses its draft, the notification feed resets, and the user hits a full-page error screen for a widget that represents roughly 8 % of the visible surface area.

That is the failure mode this page addresses: choosing a boundary scope that is too coarse for the failure being caught. The inverse problem β€” too many granular boundaries with no root safety net β€” is equally real, and the second half of this page covers it.


Architecture of the decision #

Before writing any boundary code, classify each potential failure along two axes:

  1. Blast radius: does this failure corrupt shared application state (router, auth context, global store), or is it isolated to a subtree with its own data source?
  2. Recovery target: can the user continue their task if only the affected component resets, or does a full navigation reset make more sense?

The following diagram maps these two axes to the correct boundary placement:

Global vs Local Boundary Decision Matrix A 2Γ—2 grid. X-axis: blast radius (isolated subtree to shared app state). Y-axis: recovery target (widget reset to full navigation reset). Top-right quadrant and bottom-right quadrant map to Global boundary. Top-left and bottom-left map to Local boundary. Isolated subtree Shared app state Widget reset Full nav reset Local boundary targeted reset only Global boundary + local for isolation Local boundary + optional nav reset Global boundary full page fallback Blast radius β†’

Zero-to-working code: the three-layer boundary stack #

The pattern below installs a root global boundary, a route-level intermediate boundary, and a component-level local boundary. Paste this directly into a React + TypeScript project; it has no runtime dependencies beyond React 18.

import React, {
  Component,
  type ErrorInfo,
  type ReactNode,
  useState,
  useEffect,
  useCallback,
} from 'react';

// ─── 1. Shared telemetry hook ───────────────────────────────────────────────

function generateCorrelationId(): string {
  return `spa_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}

function sendToTelemetry(error: Error, meta: Record<string, unknown>): void {
  // Replace with your real telemetry pipeline (Sentry, Datadog, etc.)
  console.error('[telemetry]', { error, meta });
}

// ─── 2. Root global boundary (class component β€” required by React) ──────────

interface GlobalBoundaryState {
  error: Error | null;
  correlationId: string;
}

export class GlobalErrorBoundary extends Component<
  { children: ReactNode; fallback: (error: Error, reset: () => void) => ReactNode },
  GlobalBoundaryState
> {
  state: GlobalBoundaryState = { error: null, correlationId: '' };

  static getDerivedStateFromError(error: Error): GlobalBoundaryState {
    return { error, correlationId: generateCorrelationId() };
  }

  componentDidCatch(error: Error, info: ErrorInfo): void {
    sendToTelemetry(error, {
      correlationId: this.state.correlationId,
      componentStack: info.componentStack,
      scope: 'global',
    });
  }

  reset = () => this.setState({ error: null, correlationId: '' });

  render() {
    const { error } = this.state;
    if (error) return this.props.fallback(error, this.reset);
    return this.props.children;
  }
}

// ─── 3. Async rejection catcher (complements the class boundary above) ──────
// React class boundaries only intercept render-phase throws. Promise rejections
// that escape render (data fetching, event handlers) need this separate listener.

export function useGlobalRejectionGuard(correlationId: string): boolean {
  const [hasAsyncError, setHasAsyncError] = useState(false);

  useEffect(() => {
    const handler = (event: PromiseRejectionEvent) => {
      event.preventDefault(); // suppress "Uncaught (in promise)" browser noise
      setHasAsyncError(true);
      sendToTelemetry(
        event.reason instanceof Error ? event.reason : new Error(String(event.reason)),
        { correlationId, scope: 'unhandledrejection' }
      );
    };
    window.addEventListener('unhandledrejection', handler);
    return () => window.removeEventListener('unhandledrejection', handler);
  }, [correlationId]);

  return hasAsyncError;
}

// ─── 4. Local component boundary ────────────────────────────────────────────

interface LocalBoundaryProps {
  children: ReactNode;
  fallback: (error: Error, reset: () => void) => ReactNode;
  /** Return false to let the error bubble to the next ancestor boundary */
  shouldCatch?: (error: Error) => boolean;
  onError?: (error: Error) => void;
}

interface LocalBoundaryState { error: Error | null }

export class LocalErrorBoundary extends Component<LocalBoundaryProps, LocalBoundaryState> {
  state: LocalBoundaryState = { error: null };

  static getDerivedStateFromError(error: Error): LocalBoundaryState {
    return { error };
  }

  componentDidCatch(error: Error, info: ErrorInfo): void {
    const { shouldCatch, onError } = this.props;
    // If the caller says "don't catch this", re-throw so a parent boundary handles it.
    if (shouldCatch && !shouldCatch(error)) throw error;
    onError?.(error);
    sendToTelemetry(error, { componentStack: info.componentStack, scope: 'local' });
  }

  reset = () => this.setState({ error: null });

  render() {
    const { error } = this.state;
    if (error) return this.props.fallback(error, this.reset);
    return this.props.children;
  }
}

Step-by-step explanation #

  1. generateCorrelationId β€” produces a timestamp-seeded ID that travels with every telemetry payload, letting you join frontend boundary events to backend trace spans.

  2. GlobalErrorBoundary.getDerivedStateFromError β€” React calls this synchronously on any render-phase throw that escapes all descendant boundaries. It stores the error and stamps a new correlation ID so the componentDidCatch payload is self-consistent.

  3. GlobalErrorBoundary.componentDidCatch β€” fires after the tree has unmounted. Emitting telemetry here (not in getDerivedStateFromError) ensures you have the componentStack string alongside the error object. Both run in the same event-loop turn.

  4. useGlobalRejectionGuard β€” React class boundaries are render-phase only. An unhandledrejection listener is the only mechanism that intercepts async throws from event handlers and fire-and-forget data fetches; mount it in the same component that renders GlobalErrorBoundary so the two share a correlation ID.

  5. LocalErrorBoundary.shouldCatch β€” the escape hatch. Pass (err) => err instanceof DataParseError to consume only domain errors and let infrastructure errors (network timeouts, auth failures) bubble upward to the global boundary where they warrant a full-page response.

  6. LocalErrorBoundary.reset β€” resets only this.state.error; sibling component trees are unaffected and keep their own state. This is the key isolation benefit over triggering a global reset.


Edge cases to handle before shipping #

Scenario Symptom Mitigation
Concurrent mode state tearing Fallback UI and recovered UI render inconsistent data simultaneously Wrap state reads that straddle a boundary activation in useSyncExternalStore so reads are always from a consistent snapshot
Route transition during error state Stale navigation guards block the recovery navigation Call router.replace(currentRoute) inside the global boundary reset to force a clean route entry
Nested local boundaries both claim the same error Double-logging, confusing telemetry Use shouldCatch to narrow each boundary to specific error classes; only one boundary in the chain should match a given error type
SSR hydration mismatch triggers global boundary on first client render Full-page fallback on page load, before any user interaction Gate boundary activation with a useEffect-managed isMounted flag; suppress the boundary render until hydration is confirmed complete

For more on state reset after errors, including how to handle uncaught promise rejections that leave dangling subscriptions, see the dedicated cleanup protocols page.


Verification steps #

After wiring the three-layer stack, confirm correct behaviour with these checks:

Local isolation β€” widget crash must not touch siblings:

// In a test environment (Vitest + @testing-library/react):
it('local boundary reset preserves sibling state', async () => {
  const { getByTestId } = render(
    <>
      <LocalErrorBoundary fallback={(_, reset) => <button onClick={reset}>Reset</button>}>
        <ThrowOnMount />
      </LocalErrorBoundary>
      <input data-testid="sibling" defaultValue="preserved" />
    </>
  );
  // Sibling input keeps its value after the boundary catches ThrowOnMount
  expect(getByTestId('sibling')).toHaveValue('preserved');
});

Global escalation β€” unknown errors must reach the root boundary:

it('unknown errors bubble past local boundary to global', () => {
  const globalSpy = vi.fn();
  const { getByText } = render(
    <GlobalErrorBoundary fallback={(err) => { globalSpy(); return <p>Global fallback</p>; }}>
      <LocalErrorBoundary
        fallback={() => <p>Local fallback</p>}
        shouldCatch={(err) => err instanceof DataParseError}
      >
        <ThrowUnknownError />
      </LocalErrorBoundary>
    </GlobalErrorBoundary>
  );
  expect(getByText('Global fallback')).toBeInTheDocument();
  expect(globalSpy).toHaveBeenCalledOnce();
});

DevTools check: open React DevTools and trigger a throw inside a locally bounded widget. The components panel should show the LocalErrorBoundary in error state while all sibling component trees show their normal state.


Frequently Asked Questions #

When does a local error boundary justify the extra component wrapper overhead?

Whenever the subtree has an independent data source, mounts third-party code, or can degrade gracefully without breaking the rest of the UI β€” the isolation benefit outweighs the wrapper cost every time.

Why can’t a single global boundary handle everything?

A global boundary replaces the entire rendered tree on activation. For failures inside a single chart widget or autocomplete, that destroys unrelated form state and forces users through a full reload when a targeted widget reset would suffice. See implementing fallback rendering without layout shift for the rendering-cost breakdown.

How do I stop a local boundary from silently swallowing errors that should be escalated?

Pass a shouldCatch predicate that returns false for unknown or critical error classes. React will then let those throws bubble up to the next ancestor boundary rather than consuming them locally. Pair this with the pattern from preventing error boundary swallowing in nested components for deep trees.