Chapter 21

Error Boundaries and a Complete Error Handling System

In a React application, a single unhandled JavaScript error can tear down the entire component tree, leaving the user staring at a blank screen with no feedback and no path to recovery. This is not a bug in React—it is a deliberate design decision. React's position is that rendering a corrupted UI is more dangerous than rendering nothing at all. But "failing explicitly" requires engineering support: error boundaries, structured error handling strategies, and monitoring.

Why Unhandled Errors Crash the Entire Tree

During rendering, every component's render function (or function component body) executes synchronously. If one throws an exception, React has no way to continue rendering that component or its children—they are in an undefined state. Before React 16, React would leave whatever UI was displayed at the time of the crash, often a partially-rendered, logically inconsistent state. This was worse than a blank screen because users could continue interacting with broken UI and produce corrupt data.

React 16 introduced error boundaries and established a clear rule: an unhandled error during the render phase unmounts the entire React tree. This forces developers to handle errors proactively rather than hoping a broken UI continues to work.

Implementing an Error Boundary Class Component

Error boundaries must be class components. They depend on two lifecycle methods that have no Hook equivalents in function components (this remains true in React 19).

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

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

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode | ((error: Error) => ReactNode);
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
}

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

  // Static method: derives new state from the error.
  // Called during the render phase; must be a pure function with no side effects.
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  // Instance method: runs after the error is confirmed.
  // Called during the commit phase (after the DOM has been updated).
  // Appropriate for side effects like logging and error reporting.
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('ErrorBoundary caught:', error, errorInfo);
    this.props.onError?.(error, errorInfo);
  }

  render() {
    if (this.state.hasError && this.state.error) {
      const { fallback } = this.props;
      if (typeof fallback === 'function') {
        return fallback(this.state.error);
      }
      return fallback ?? <div>Something went wrong.</div>;
    }
    return this.props.children;
  }
}

The division of labor between the two lifecycle methods is precise. getDerivedStateFromError runs during rendering and must update state synchronously so that React can render the fallback UI. componentDidCatch runs after the commit phase, making it safe to trigger side effects such as calling an error reporting API.

react-error-boundary: A Production-Ready Library

Building a robust error boundary from scratch requires handling many edge cases: state reset, retry logic, and the key reset trick for re-mounting children. The react-error-boundary library handles all of them:

npm install react-error-boundary

Basic Usage

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

function ErrorFallback({
  error,
  resetErrorBoundary,
}: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert">
      <p>Something went wrong: {error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => {
        reportError(error, info); // report to Sentry, Datadog, etc.
      }}
      onReset={() => {
        // reset application state: clear cache, reset store, etc.
      }}
    >
      <UserDashboard />
    </ErrorBoundary>
  );
}

The useErrorBoundary Hook

react-error-boundary exposes a useErrorBoundary Hook that lets function components programmatically trigger the nearest parent error boundary:

import { useErrorBoundary } from 'react-error-boundary';

function DataFetcher({ userId }: { userId: string }) {
  const { showBoundary } = useErrorBoundary();

  useEffect(() => {
    fetchUser(userId)
      .then(setUser)
      .catch((error) => {
        // Route the async error to the nearest ErrorBoundary
        showBoundary(error);
      });
  }, [userId]);

  // ...
}

This solves an important problem: error boundaries only catch errors thrown during rendering. Async errors inside useEffect or event handlers are not caught by default. showBoundary gives you a way to route those errors through the boundary system.

Resetting Error Boundaries

When an error is transient (such as a network blip), the user should be able to dismiss the error state and trigger a fresh render. resetKeys provides an elegant mechanism:

function App() {
  const [location, setLocation] = useState(window.location.pathname);

  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      resetKeys={[location]} // automatically resets when location changes
      onReset={() => setLocation(window.location.pathname)}
    >
      <Router />
    </ErrorBoundary>
  );
}

Error Boundary Placement Strategy

More boundaries are not always better, and a single global boundary is not sufficient for a real application. The right placement balances fault isolation with user experience.

App
├── GlobalErrorBoundary        ← Outermost: catch anything that escapes lower boundaries
│   ├── Header                 ← No boundary needed: if Header crashes, navigation is gone anyway
│   ├── Sidebar
│   │   └── SidebarErrorBoundary   ← Independent region: sidebar crash doesn't affect main content
│   └── Main
│       ├── RouteErrorBoundary     ← Route-level: each page is isolated
│       │   └── PageContent
│       │       ├── CardErrorBoundary   ← Widget-level: dashboard card crash stays isolated
│       │       └── ChartErrorBoundary  ← Chart isolated from table

Guiding principles:

Error Reporting with Sentry

npm install @sentry/react
// src/main.tsx
import * as Sentry from '@sentry/react';

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  environment: import.meta.env.MODE,
  integrations: [
    Sentry.browserTracingIntegration(),
    Sentry.replayIntegration(),
  ],
  tracesSampleRate: 0.1,        // sample 10% of performance traces
  replaysOnErrorSampleRate: 1,  // capture 100% of sessions with errors
});

// Sentry's ErrorBoundary wrapper automatically captures and reports errors
import { ErrorBoundary } from '@sentry/react';

function App() {
  return (
    <ErrorBoundary
      fallback={<ErrorPage />}
      showDialog // prompts user for feedback via Sentry's user feedback dialog
    >
      <Router />
    </ErrorBoundary>
  );
}

Manual reporting inside componentDidCatch:

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
  Sentry.withScope((scope) => {
    scope.setExtra('componentStack', errorInfo.componentStack);
    scope.setTag('component', 'ErrorBoundary');
    Sentry.captureException(error);
  });
}

React 19 Error Handling Improvements

React 19 introduced significant improvements to the error handling infrastructure.

New Root Options: onCaughtError and onUncaughtError

import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root')!, {
  // Errors caught by an Error Boundary
  onCaughtError(error, errorInfo) {
    console.log('Caught by boundary:', error.message);
    // errorInfo.componentStack contains the component stack trace
  },

  // Errors NOT caught by any Error Boundary (will unmount the tree)
  onUncaughtError(error, errorInfo) {
    console.error('Uncaught error, app will crash:', error.message);
    Sentry.captureException(error);
  },

  // Recoverable errors (e.g., hydration mismatches React can automatically fix)
  onRecoverableError(error, errorInfo) {
    console.warn('Recoverable error:', error.message);
  },
});

These callbacks are more precise than window.onerror or window.addEventListener('error') because they distinguish between errors that were caught by a boundary, uncaught errors that are fatal, and recoverable errors that React can heal automatically.

Clearer Hydration Error Messages

React 19 dramatically improved the error messages for SSR hydration mismatches. Previously, the message simply said "hydration mismatch." React 19 shows exactly what the server rendered versus what the client expected:

Hydration failed because the server rendered HTML didn't match the client.
  Server: <div class="theme-dark">
  Client: <div class="theme-light">

This makes debugging SSR applications—previously one of the most frustrating experiences in React—considerably more tractable.

Handling Async Errors

Error boundaries catch only errors thrown during the render phase. The following scenarios require separate handling.

Errors in Event Handlers

function DeleteButton({ onDelete }: { onDelete: () => Promise<void> }) {
  const [error, setError] = useState<Error | null>(null);
  const { showBoundary } = useErrorBoundary();

  async function handleDelete() {
    try {
      await onDelete();
    } catch (e) {
      const error = e instanceof Error ? e : new Error(String(e));

      if (error.name === 'NetworkError') {
        // Recoverable: show an inline message inside the component
        setError(error);
      } else {
        // Non-recoverable: escalate to the nearest error boundary
        showBoundary(error);
      }
    }
  }

  return (
    <>
      {error && <p role="alert" className="error">{error.message}</p>}
      <button onClick={handleDelete}>Delete</button>
    </>
  );
}

Error Handling in Data Fetching

React Query provides a mature error handling model for asynchronous data:

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading, refetch } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    retry: 3,         // automatically retry up to 3 times on failure
    retryDelay: 1000, // wait 1 second between each retry
  });

  if (isLoading) return <Skeleton />;

  if (error) {
    return (
      <div role="alert">
        <p>Failed to load user: {error.message}</p>
        <button onClick={() => refetch()}>Retry</button>
      </div>
    );
  }

  return <UserCard user={data} />;
}

A complete error handling system is not a single technology choice. It is a layered defense: render errors handled by error boundaries, async errors handled by try/catch and state management, and global visibility handled by monitoring and reporting. Each layer has clear ownership. Together they allow an application to degrade gracefully under every failure mode—rather than leaving users staring at a white screen with no recourse.

Rate this chapter
4.7  / 5  (8 ratings)

💬 Comments