Skip to content

React Error Boundaries — Error Handling Guide

DodaTech 7 min read

In this tutorial, you'll learn about React Error Boundaries. We cover key concepts, practical examples, and best practices.

What You'll Learn

Implement error boundaries in React applications — class-based boundaries, the react-error-boundary library, handling async errors, logging strategies, recovery patterns, and error reporting with TypeScript.

Why It Matters

A single unhandled JavaScript error can crash an entire React application, showing users a white screen with no explanation. Error boundaries contain crashes to the affected component, keeping the rest of the app functional and giving users a path to recover.

Real-World Use

A dashboard with ten widgets where one widget's data source fails. Without error boundaries, the entire dashboard crashes. With per-widget boundaries, the failed widget shows a "Try Again" button while the other nine widgets continue updating normally.

Learning Path

flowchart LR
    A[React Basics] --> B[Hooks Deep Dive]
    B --> C[Design Patterns]
    C --> D[Error Boundaries]
    D --> E[Testing React]
    E --> F[Final Project]
    D -->|You are here| D

What Error Boundaries Catch

Error boundaries catch JavaScript errors during:

  • Rendering (component render phase)
  • Lifecycle methods
  • Constructors of the entire tree below them

They do NOT catch:

  • Errors in event handlers (use try-catch)
  • Asynchronous errors in Promises or setTimeout
  • Errors thrown inside the error boundary itself
  • Server-side rendering errors

Building a Reusable Error Boundary

Error boundaries must be class components because React requires getDerivedStateFromError and componentDidCatch lifecycle methods. The react-error-boundary library wraps this boilerplate into a reusable hook-friendly component.

import { Component, ErrorInfo, ReactNode } from "react";

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

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

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

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

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    this.props.onError?.(error, errorInfo);
  }

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

  render() {
    if (this.state.hasError) {
      if (typeof this.props.fallback === "function") {
        return this.props.fallback(this.state.error!, this.handleReset);
      }
      return this.props.fallback || (
        <div
          role="alert"
          style={{
            padding: "24px",
            borderRadius: "8px",
            background: "#fff5f5",
            border: "1px solid #ff4444",
            textAlign: "center",
          }}
        >
          <h2>Something went wrong</h2>
          <p style={{ color: "#666" }}>
            {this.state.error?.message || "An unexpected error occurred"}
          </p>
          <button
            onClick={this.handleReset}
            style={{
              padding: "8px 16px",
              background: "#007bff",
              color: "#fff",
              border: "none",
              borderRadius: "4px",
              cursor: "pointer",
            }}
          >
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <div>
      <Header />
      <ErrorBoundary onError={(err) => console.error("Caught:", err)}>
        <UserProfile userId={42} />
      </ErrorBoundary>
      <Footer />
    </div>
  );
}

Expected output: If UserProfile throws during rendering, the boundary catches the error, logs it via the onError callback, and displays the fallback UI with an error message and "Try Again" button. The Header and Footer remain visible and functional.

Using react-error-boundary Library

The react-error-boundary package provides the same pattern as a reusable component with hook-based usage.

import { ErrorBoundary } from "react-error-boundary";
import { useState } from "react";

function FallbackComponent({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div role="alert" style={{ padding: "16px", background: "#fff3f3" }}>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>Try Again</button>
    </div>
  );
}

function BuggyCounter() {
  const [count, setCount] = useState(0);

  if (count === 5) {
    throw new Error("Count cannot be 5!");
  }

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

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

  return (
    <ErrorBoundary
      FallbackComponent={FallbackComponent}
      onReset={() => setResetKey((k) => k + 1)}
      resetKeys={[resetKey]}
    >
      <BuggyCounter key={resetKey} />
    </ErrorBoundary>
  );
}

Expected output: Clicking the counter increments the count. When count reaches 5, the component throws and the fallback renders with the error message "Count cannot be 5!" and a "Try Again" button. Clicking "Try Again" resets the boundary and remounts the counter with a fresh key.

Handling Async Errors

Error boundaries do not catch async errors. Wrap async code in try-catch and store the error in state.

import { useState } from "react";

interface DataLoaderProps {
  fetchData: () => Promise<unknown>;
  children: (data: unknown) => React.ReactNode;
}

function AsyncErrorWrapper({ fetchData, children }: DataLoaderProps) {
  const [error, setError] = useState<Error | null>(null);

  async function handleRetry() {
    setError(null);
    try {
      await fetchData();
    } catch (err) {
      setError(err as Error);
    }
  }

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

  return <>{children(null)}</>;
}

// Wrap async-aware components inside an error boundary for render errors
// AND inside AsyncErrorWrapper for fetch errors
function SafeWidget({ id }: { id: string }) {
  return (
    <ErrorBoundary fallback={<p>Widget crashed</p>}>
      <AsyncErrorWrapper fetchData={() => fetch(`/api/widget/${id}`)}>
        {(data) => <WidgetDisplay data={data} />}
      </AsyncErrorWrapper>
    </ErrorBoundary>
  );
}

Expected output: Network failures show "Failed to load data" with a Retry button. The ErrorBoundary catches any rendering errors in WidgetDisplay. The combination covers both async and render-time errors.

Logging Strategy for Error Boundaries

Never let errors go silently. Log to console in development and to a monitoring service in production.

import { ErrorInfo } from "react";

interface ErrorLogPayload {
  message: string;
  stack: string | undefined;
  componentStack: string;
  url: string;
  userAgent: string;
  timestamp: string;
}

function logError(error: Error, errorInfo: ErrorInfo): void {
  const payload: ErrorLogPayload = {
    message: error.message,
    stack: error.stack,
    componentStack: errorInfo.componentStack || "",
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: new Date().toISOString(),
  };

  if (process.env.NODE_ENV === "development") {
    console.group("Error Boundary caught an error:");
    console.error("Error:", error);
    console.error("Component stack:", errorInfo.componentStack);
    console.groupEnd();
  } else {
    // Send to monitoring service
    fetch("/api/log-error", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    }).catch(() => {
      // Silent fail — logging should never crash the app
    });
  }
}

function App() {
  return (
    <ErrorBoundary onError={logError}>
      <Dashboard />
    </ErrorBoundary>
  );
}

Security note: Strip sensitive data (auth tokens, PII) from error logs before sending to external services.

Error Recovery Patterns

Provide users with clear recovery paths. The simplest approach is a key change to force a remount.

import { useState } from "react";
import { ErrorBoundary } from "react-error-boundary";

function FallbackUI({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div style={{ textAlign: "center", padding: "32px" }}>
      <p>This section encountered a problem.</p>
      <p style={{ fontSize: "12px", color: "#999" }}>
        {error.message}
      </p>
      <button
        onClick={resetErrorBoundary}
        style={{
          padding: "8px 16px",
          background: "#28a745",
          color: "#fff",
          border: "none",
          borderRadius: "4px",
          cursor: "pointer",
        }}
      >
        Reload Section
      </button>
    </div>
  );
}

function ResilientWidget({ widgetId }: { widgetId: string }) {
  const [key, setKey] = useState(0);

  return (
    <ErrorBoundary
      key={key}
      FallbackComponent={(props) => (
        <FallbackUI {...props} resetErrorBoundary={() => setKey((k) => k + 1)} />
      )}
    >
      <DataWidget widgetId={widgetId} />
    </ErrorBoundary>
  );
}

Expected output: When the widget fails, the user sees "This section encountered a problem." with a "Reload Section" button. Clicking it changes the key, unmounts the error boundary, and remounts it fresh — the component starts from a clean state.

Common Mistakes

1. Placing One Global Boundary at the Root

One boundary catches everything but shows a single error screen for any failure. Use per-section boundaries so one broken widget does not hide the entire page.

2. Not Implementing getDerivedStateFromError

If you only implement componentDidCatch without getDerivedStateFromError, the error is logged but the broken tree still renders. You must update state to switch to fallback UI.

3. Forgetting Boundaries Don't Catch Async Errors

Async errors in fetch, setTimeout, or event handlers must be caught with try-catch. Do not expect error boundaries to handle them.

Practice Questions

Can I use error boundaries with functional components and hooks?

No. Error boundaries must be class components because hooks do not have equivalents for getDerivedStateFromError and componentDidCatch. Use the react-error-boundary library which wraps the class component boilerplate into a reusable component.

How do I test error boundary behavior?

Create a component that throws a controlled error. Wrap it in the boundary. Assert that the fallback UI renders and the original content does not. For the react-error-boundary library, mock the error in a test by rendering a child that throws on mount.

What is the best strategy for placing error boundaries in a large app?

Start with one boundary per route section. Add per-widget boundaries for independent features. Keep the root boundary as a last resort for truly unexpected failures. This gives granular recovery without overwhelming the user.

Challenge

Build a withErrorBoundary HOC that wraps any component with error boundary behavior. It should accept: fallback (React node or render prop), onError callback, and onReset callback. The wrapped component should re-mount when the boundary resets.

Real-World Task

Add error boundaries to an existing React app. Place one boundary around the main content area per page. Place per-widget boundaries around independent features (charts, feeds, forms). Add a logging function that sends error details to an API endpoint. Verify that a simulated crash in one widget does not affect others.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications where error boundaries prevent crashes from disrupting thousands of active users.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro