MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Error Boundaries: Graceful Error Handling

Implement error boundaries: component-level errors, fallback UI, and recovery patterns.

ReactError BoundariesError HandlingFrontend

By MinhVo

Introduction

Error boundaries are React's mechanism for handling JavaScript errors in component trees gracefully. Introduced in React 16, error boundaries catch errors during rendering, in lifecycle methods, and in constructors of the entire tree below them. Instead of crashing the entire application, error boundaries display a fallback UI and log the error for debugging.

Before error boundaries, a JavaScript error in any component would unmount the entire React component tree, leaving users with a blank screen. This was particularly problematic in production applications where a single rendering error in a non-critical component would take down the entire application.

Error boundaries solve this by providing component-level error isolation. A failing component is contained within its error boundary, while the rest of the application continues to function normally. This is similar to how try/catch works in JavaScript, but specifically designed for React's declarative component model.

This guide covers everything you need to know about error boundaries: from basic implementation to advanced recovery patterns, testing strategies, and production best practices.

Error handling patterns

Understanding Error Boundaries: Core Concepts

What Error Boundaries Catch

Error boundaries catch errors that occur during:

  1. Rendering: When a component's render method or function body throws
  2. Lifecycle methods: componentDidMount, componentDidUpdate, getDerivedStateFromError
  3. Constructors: Class component constructors
  4. Inside the error boundary tree: Any component below the boundary in the tree
// This error WILL be caught by an error boundary
function BuggyComponent() {
  const data = undefined;
  return <div>{data.map(item => <span key={item.id}>{item.name}</span>)}</div>;
  // TypeError: Cannot read property 'map' of undefined
}

What Error Boundaries Don't Catch

Error boundaries do NOT catch errors in:

  1. Event handlers: Errors in onClick, onChange, etc.
  2. Asynchronous code: setTimeout, Promise, async/await
  3. Server-side rendering: Errors during SSR
  4. The error boundary itself: Errors in the boundary's own render method
// This error will NOT be caught by an error boundary
function ComponentWithEventHandler() {
  const handleClick = () => {
    // This error happens outside the render cycle
    throw new Error('Click handler error');
  };
 
  return <button onClick={handleClick}>Click me</button>;
}
 
// Handle these with regular try/catch
function ComponentWithAsyncError() {
  useEffect(() => {
    fetchData().catch(error => {
      // Handle async errors manually
      console.error('Async error:', error);
    });
  }, []);
}

Error Boundary Placement

The placement of error boundaries in your component tree determines what gets isolated:

function App() {
  return (
    <div>
      <Header />
      <ErrorBoundary fallback={<SidebarError />}>
        <Sidebar />
      </ErrorBoundary>
      <ErrorBoundary fallback={<ContentError />}>
        <Content />
      </ErrorBoundary>
      <Footer />
    </div>
  );
}

If Sidebar crashes, only the sidebar shows the error fallback. The header, content, and footer continue to work.

Component tree with boundaries

Architecture and Design Patterns

Class Component Error Boundary

The only way to create an error boundary is with a class component (as of React 18):

interface ErrorBoundaryProps {
  fallback: React.ReactNode;
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
  onReset?: () => void;
  resetKeys?: Array<unknown>;
}
 
interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}
 
class ErrorBoundary extends React.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: React.ErrorInfo) {
    // Log error to monitoring service
    console.error('Error caught by boundary:', error, errorInfo);
    this.props.onError?.(error, errorInfo);
  }
 
  componentDidUpdate(prevProps: ErrorBoundaryProps) {
    if (this.state.hasError) {
      // Reset when resetKeys change
      if (this.props.resetKeys &&
          this.props.resetKeys.some((key, i) => key !== prevProps.resetKeys?.[i])) {
        this.reset();
      }
    }
  }
 
  reset() {
    this.setState({ hasError: false, error: null });
    this.props.onReset?.();
  }
 
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

Hook-Based Error Boundary (Third-Party)

Libraries like react-error-boundary provide hook-based APIs:

import { ErrorBoundary, useErrorBoundary } from 'react-error-boundary';
 
function App() {
  return (
    <ErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => (
        <ErrorFallback error={error} onRetry={resetErrorBoundary} />
      )}
      onReset={() => {
        // Reset application state
      }}
    >
      <MainContent />
    </ErrorBoundary>
  );
}
 
function ComponentWithError() {
  const { showBoundary } = useErrorBoundary();
 
  const handleAsyncOperation = async () => {
    try {
      await riskyOperation();
    } catch (error) {
      showBoundary(error);
    }
  };
}

Recovery Patterns

Automatic Retry with Reset Keys:

function ErrorBoundaryWithRetry({ children, retryKey }) {
  return (
    <ErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div>
          <p>Error: {error.message}</p>
          <button onClick={resetErrorBoundary}>Try Again</button>
        </div>
      )}
      resetKeys={[retryKey]}
    >
      {children}
    </ErrorBoundary>
  );
}
 
// Usage
function App() {
  const [retryKey, setRetryKey] = useState(0);
 
  return (
    <ErrorBoundaryWithRetry retryKey={retryKey}>
      <UnstableComponent />
      <button onClick={() => setRetryKey(k => k + 1)}>Reset Component</button>
    </ErrorBoundaryWithRetry>
  );
}

Fallback with Navigation:

function PageErrorFallback({ error }) {
  const navigate = useNavigate();
 
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error.message}</p>
      <button onClick={() => navigate('/')}>Go Home</button>
      <button onClick={() => navigate(-1)}>Go Back</button>
    </div>
  );
}

Step-by-Step Implementation

Step 1: Create a Reusable Error Boundary

// components/ErrorBoundary.tsx
interface Props {
  children: React.ReactNode;
  fallback?: React.ReactNode;
  onError?: (error: Error, info: React.ErrorInfo) => void;
}
 
interface State {
  hasError: boolean;
  error: Error | null;
}
 
class ErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false, error: null };
 
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }
 
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    this.props.onError?.(error, errorInfo);
  }
 
  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <DefaultErrorFallback error={this.state.error!} />;
    }
    return this.props.children;
  }
}
 
function DefaultErrorFallback({ error }: { error: Error }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
    </div>
  );
}

Step 2: Wrap Critical Sections

function App() {
  return (
    <ErrorBoundary>
      <Header />
      <ErrorBoundary fallback={<SidebarError />}>
        <Sidebar />
      </ErrorBoundary>
      <ErrorBoundary fallback={<MainContentError />}>
        <MainContent />
      </ErrorBoundary>
    </ErrorBoundary>
  );
}

Step 3: Add Error Logging

function logErrorToService(error: Error, errorInfo: React.ErrorInfo) {
  // Send to error tracking service
  fetch('/api/errors', {
    method: 'POST',
    body: JSON.stringify({
      message: error.message,
      stack: error.stack,
      componentStack: errorInfo.componentStack,
      url: window.location.href,
      userAgent: navigator.userAgent,
      timestamp: new Date().toISOString(),
    }),
  });
}
 
<ErrorBoundary onError={logErrorToService}>
  <App />
</ErrorBoundary>

Step 4: Handle Async Errors

function useAsyncError() {
  const [, setError] = useState();
  return (error: Error) => {
    setError(() => {
      throw error;
    });
  };
}
 
function DataFetcher({ url }) {
  const [data, setData] = useState(null);
  const throwError = useAsyncError();
 
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(throwError);
  }, [url]);
 
  return data ? <Display data={data} /> : <Loading />;
}

Step 5: Testing Error Boundaries

import { render, screen } from '@testing-library/react';
 
function ThrowingComponent({ shouldThrow }) {
  if (shouldThrow) {
    throw new Error('Test error');
  }
  return <div>Working</div>;
}
 
describe('ErrorBoundary', () => {
  beforeEach(() => {
    jest.spyOn(console, 'error').mockImplementation(() => {});
  });
 
  afterEach(() => {
    jest.restoreAllMocks();
  });
 
  it('renders children when no error', () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <ThrowingComponent shouldThrow={false} />
      </ErrorBoundary>
    );
    expect(screen.getByText('Working')).toBeInTheDocument();
  });
 
  it('renders fallback when error occurs', () => {
    render(
      <ErrorBoundary fallback={<div>Error occurred</div>}>
        <ThrowingComponent shouldThrow={true} />
      </ErrorBoundary>
    );
    expect(screen.getByText('Error occurred')).toBeInTheDocument();
  });
 
  it('calls onError callback', () => {
    const onError = jest.fn();
    render(
      <ErrorBoundary fallback={<div>Error</div>} onError={onError}>
        <ThrowingComponent shouldThrow={true} />
      </ErrorBoundary>
    );
    expect(onError).toHaveBeenCalledWith(
      expect.any(Error),
      expect.objectContaining({ componentStack: expect.any(String) })
    );
  });
});

Recovery flow diagram

Real-World Use Cases

Use Case 1: Micro-Frontend Isolation

function MicroFrontend({ name, url }) {
  return (
    <ErrorBoundary
      fallback={<MicroFrontendError name={name} />}
      onError={(error) => reportMicroFrontendError(name, error)}
    >
      <Suspense fallback={<Loading />}>
        <RemoteComponent url={url} />
      </Suspense>
    </ErrorBoundary>
  );
}
 
function App() {
  return (
    <div>
      <Header />
      <ErrorBoundary fallback={<WidgetError name="Dashboard" />}>
        <DashboardWidget />
      </ErrorBoundary>
      <ErrorBoundary fallback={<WidgetError name="Analytics" />}>
        <AnalyticsWidget />
      </ErrorBoundary>
    </div>
  );
}

Use Case 2: Form Error Recovery

function FormWithRecovery() {
  const [formKey, setFormKey] = useState(0);
 
  return (
    <ErrorBoundary
      key={formKey}
      fallback={
        <div>
          <p>The form encountered an error</p>
          <button onClick={() => setFormKey(k => k + 1)}>Reset Form</button>
        </div>
      }
    >
      <ComplexForm />
    </ErrorBoundary>
  );
}

Use Case 3: Third-Party Widget Isolation

function ThirdPartyWidget({ config }) {
  return (
    <ErrorBoundary
      fallback={
        <div>
          <p>Widget failed to load</p>
          <button onClick={() => window.location.reload()}>Reload Page</button>
        </div>
      }
    >
      <ThirdPartyScript config={config} />
    </ErrorBoundary>
  );
}

Best Practices for Production

  1. Place boundaries strategically: Wrap sections that can fail independently without affecting the rest of the UI.

  2. Provide meaningful fallbacks: Don't just show "Error occurred." Give users context and actions they can take.

  3. Log errors to a service: Use componentDidCatch to send errors to Sentry, LogRocket, or your own logging service.

  4. Use reset keys: Implement resetKeys to automatically retry rendering when dependencies change.

  5. Don't overuse boundaries: Too many error boundaries create fragmented error states. One per major section is usually sufficient.

  6. Test error states: Write tests that verify error boundaries catch errors and display fallbacks correctly.

  7. Handle async errors separately: Error boundaries don't catch async errors. Use try/catch and useErrorBoundary for those.

  8. Provide recovery actions: Give users buttons to retry, navigate home, or contact support.

Common Pitfalls and Solutions

PitfallImpactSolution
Catching too broadlyMasks errors, hard to debugPlace boundaries at section level, not app root
Empty fallback UIUsers see blank screenAlways provide informative fallback with actions
Not logging errorsLost debugging contextUse componentDidCatch to log to monitoring service
Expecting boundary to catch async errorsErrors still crash the appUse try/catch for async, showBoundary for hooks
No recovery mechanismUsers stuck on error pageImplement reset buttons or automatic retries
Testing without suppressing console.errorTest output is noisyMock console.error in test setup

Performance Optimization

Error boundaries have minimal performance impact:

  • No overhead when no errors: The boundary's render method simply returns children
  • State update on error: Only triggers when an error occurs
  • Re-render scope: Only the boundary and its fallback re-render on error
// Optimized error boundary that doesn't re-render unnecessarily
class OptimizedErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false, error: null };
 
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }
 
  shouldComponentUpdate(nextProps: Props, nextState: State) {
    // Only re-render if error state changed or children changed
    return (
      nextState.hasError !== this.state.hasError ||
      nextProps.children !== this.props.children
    );
  }
 
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

Comparison with Alternatives

FeatureError Boundarytry/catchwindow.onerrorError Pages
ScopeComponent treeCode blockGlobalRoute
UI RecoveryFallback componentManualManualFull page
GranularityPer-sectionPer-blockApplicationPer-route
Async ErrorsNoYesYesYes
React IntegrationDeepNoneNoneNone
User ExperienceSeamlessDependsDependsFull reload

Advanced Patterns

Nested Error Boundaries with Fallback Chain

function App() {
  return (
    <ErrorBoundary fallback={<AppLevelError />}>
      <Layout>
        <ErrorBoundary fallback={<SectionLevelError />}>
          <MainContent>
            <ErrorBoundary fallback={<ComponentLevelError />}>
              <UnstableComponent />
            </ErrorBoundary>
          </MainContent>
        </ErrorBoundary>
      </Layout>
    </ErrorBoundary>
  );
}

Error Boundary with Suspense Integration

function DataBoundary({ children, fallback }) {
  return (
    <ErrorBoundary fallback={fallback}>
      <Suspense fallback={<Loading />}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}
 
// Usage
<DataBoundary fallback={<DataError />}>
  <AsyncDataComponent />
</DataBoundary>

Global Error Handler Hook

function useGlobalErrorHandler() {
  const { showBoundary } = useErrorBoundary();
 
  useEffect(() => {
    const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
      showBoundary(event.reason);
    };
 
    const handleError = (event: ErrorEvent) => {
      showBoundary(event.error);
    };
 
    window.addEventListener('unhandledrejection', handleUnhandledRejection);
    window.addEventListener('error', handleError);
 
    return () => {
      window.removeEventListener('unhandledrejection', handleUnhandledRejection);
      window.removeEventListener('error', handleError);
    };
  }, [showBoundary]);
}

Testing Strategies

Snapshot Testing Error Fallbacks

it('matches error fallback snapshot', () => {
  const { container } = render(
    <ErrorBoundary fallback={<ErrorFallback />}>
      <ThrowingComponent />
    </ErrorBoundary>
  );
  expect(container).toMatchSnapshot();
});

Integration Testing Error Recovery

it('recovers after reset', async () => {
  const { rerender } = render(
    <ErrorBoundary fallback={<div>Error</div>} resetKeys={[1]}>
      <ThrowingComponent shouldThrow={true} />
    </ErrorBoundary>
  );
 
  expect(screen.getByText('Error')).toBeInTheDocument();
 
  rerender(
    <ErrorBoundary fallback={<div>Error</div>} resetKeys={[2]}>
      <ThrowingComponent shouldThrow={false} />
    </ErrorBoundary>
  );
 
  expect(screen.queryByText('Error')).not.toBeInTheDocument();
  expect(screen.getByText('Working')).toBeInTheDocument();
});

Error Boundaries with React 19 and Server Components

Error Handling in React Server Components

React Server Components introduce new error handling patterns that complement error boundaries:

// app/dashboard/page.tsx (Server Component)
// Use error.tsx for route-level error handling in Next.js App Router
export default async function DashboardPage() {
  const data = await fetchDashboardData(); // If this throws, error.tsx catches it
  return <Dashboard data={data} />;
}
 
// app/dashboard/error.tsx (Client Component)
'use client';
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong loading the dashboard</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

Combining Error Boundaries with Suspense

Error boundaries work naturally with Suspense for loading and error states:

function UserProfile({ userId }: { userId: string }) {
  return (
    <ErrorBoundary
      fallbackRender={({ error, resetErrorBoundary }) => (
        <div>
          <p>Failed to load profile: {error.message}</p>
          <button onClick={resetErrorBoundary}>Retry</button>
        </div>
      )}
    >
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfileContent userId={userId} />
      </Suspense>
    </ErrorBoundary>
  );
}

Streaming Error Handling

For streaming SSR, errors may occur after the initial response has started:

// Server-side streaming with error handling
async function* StreamPage() {
  try {
    yield <Header />;
    yield <Suspense fallback={<ContentSkeleton />}>
      <AsyncContent />
    </Suspense>;
    yield <Footer />;
  } catch (error) {
    yield <ErrorBanner message="An error occurred while loading this page" />;
  }
}

Production Error Reporting Integration

Sentry Integration

Connect error boundaries to Sentry for production error tracking:

import * as Sentry from '@sentry/react';
 
function App() {
  return (
    <Sentry.ErrorBoundary
      fallback={({ error, resetError }) => (
        <ErrorFallback error={error} onRetry={resetError} />
      )}
      beforeCapture={(scope) => {
        scope.setTag('errorBoundary', true);
        scope.setLevel('error');
      }}
      onError={(error, componentStack) => {
        // Custom error processing
        console.error('Error boundary caught:', error);
      }}
      showDialog
    >
      <AppContent />
    </Sentry.ErrorBoundary>
  );
}
 
// Custom error boundary with Sentry
class SentryErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false, error: null };
 
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }
 
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    Sentry.withScope((scope) => {
      scope.setExtras(errorInfo);
      scope.setTag('component', 'ErrorBoundary');
      Sentry.captureException(error);
    });
  }
 
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

Custom Error Reporting Service

Build a custom error reporting pipeline:

interface ErrorReport {
  message: string;
  stack?: string;
  componentStack?: string;
  url: string;
  userAgent: string;
  timestamp: string;
  userId?: string;
  sessionId: string;
}
 
function reportError(error: Error, errorInfo: React.ErrorInfo) {
  const report: ErrorReport = {
    message: error.message,
    stack: error.stack,
    componentStack: errorInfo.componentStack,
    url: window.location.href,
    userAgent: navigator.userAgent,
    timestamp: new Date().toISOString(),
    userId: getCurrentUserId(),
    sessionId: getSessionId(),
  };
 
  // Send to monitoring endpoint
  fetch('/api/errors', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(report),
  }).catch(() => {
    // Silently fail - don't error on error reporting
  });
}
 
<ErrorBoundary
  onError={(error, errorInfo) => reportError(error, errorInfo)}
  fallback={<ErrorFallback />}
>
  <AppContent />
</ErrorBoundary>

Testing Error Boundaries

Testing with React Testing Library

import { render, screen, fireEvent } from '@testing-library/react';
 
// Suppress console.error for expected errors
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
 
afterAll(() => consoleSpy.mockRestore());
 
describe('ErrorBoundary', () => {
  it('renders children when no error occurs', () => {
    render(
      <ErrorBoundary fallback={<div>Error occurred</div>}>
        <div>Normal content</div>
      </ErrorBoundary>
    );
    expect(screen.getByText('Normal content')).toBeInTheDocument();
  });
 
  it('renders fallback when child throws', () => {
    const ThrowComponent = () => {
      throw new Error('Test error');
    };
 
    render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowComponent />
      </ErrorBoundary>
    );
    expect(screen.getByText('Something went wrong')).toBeInTheDocument();
  });
 
  it('calls onError callback when error occurs', () => {
    const onError = jest.fn();
    const ThrowComponent = () => {
      throw new Error('Test error');
    };
 
    render(
      <ErrorBoundary fallback={<div>Error</div>} onError={onError}>
        <ThrowComponent />
      </ErrorBoundary>
    );
 
    expect(onError).toHaveBeenCalledWith(
      expect.objectContaining({ message: 'Test error' }),
      expect.objectContaining({ componentStack: expect.any(String) })
    );
  });
 
  it('recovers when resetKeys change', () => {
    const { rerender } = render(
      <ErrorBoundary fallback={<div>Error</div>} resetKeys={[1]}>
        <ThrowingComponent shouldThrow={true} />
      </ErrorBoundary>
    );
 
    expect(screen.getByText('Error')).toBeInTheDocument();
 
    rerender(
      <ErrorBoundary fallback={<div>Error</div>} resetKeys={[2]}>
        <ThrowingComponent shouldThrow={false} />
      </ErrorBoundary>
    );
 
    expect(screen.queryByText('Error')).not.toBeInTheDocument();
  });
});

E2E Testing Error Scenarios

// Cypress E2E test
describe('Error handling', () => {
  it('shows error fallback when API fails', () => {
    // Intercept API call and force failure
    cy.intercept('GET', '/api/users', { statusCode: 500 });
    cy.visit('/users');
 
    cy.get('[data-testid="error-boundary"]')
      .should('be.visible')
      .and('contain', 'Failed to load users');
 
    // Test recovery
    cy.intercept('GET', '/api/users', { fixture: 'users.json' });
    cy.get('[data-testid="retry-button"]').click();
    cy.get('[data-testid="user-list"]').should('be.visible');
  });
});

Future Outlook

  • React Compiler integration: Automatic error boundary placement based on component analysis
  • Server Component error handling: Better error boundaries for server-rendered content
  • Improved async error handling: Native support for catching async errors in boundaries
  • Error recovery patterns: Built-in support for common recovery strategies

Conclusion

Error boundaries are essential for building resilient React applications. They provide component-level error isolation that prevents a single failure from crashing your entire application.

Key takeaways:

  1. Error boundaries catch rendering errors, not event handler or async errors
  2. Place boundaries strategically at section boundaries that can fail independently
  3. Provide meaningful fallback UI with context and recovery actions
  4. Log errors to monitoring services for debugging in production
  5. Use reset keys for automatic recovery when dependencies change
  6. Handle async errors separately with try/catch and useErrorBoundary
  7. Test error states to verify boundaries work correctly

Error boundaries are your safety net in production. Implement them thoughtfully, and your users will never see a blank white screen again.