Front-end Engineering Lab

Error Boundary Strategies

Granular error isolation with strategic error boundary placement

Error Boundary Strategies

Error boundaries prevent one component's error from crashing your entire application. Strategic placement creates resilient apps with isolated failure zones.

What are Error Boundaries?

Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI.

What they catch:

  • Rendering errors
  • Lifecycle method errors
  • Constructor errors in child components

What they DON'T catch:

  • Event handlers (use try-catch)
  • Asynchronous code (use error states)
  • Server-side rendering errors
  • Errors in the error boundary itself

Basic Error Boundary

// components/ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react';

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

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

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

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

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Log to error reporting service
    console.error('Error boundary caught:', error, errorInfo);
    
    // Call custom error handler
    this.props.onError?.(error, errorInfo);
  }

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

  render() {
    if (this.state.hasError && this.state.error) {
      // Custom fallback
      if (typeof this.props.fallback === 'function') {
        return this.props.fallback(this.state.error, this.reset);
      }
      
      if (this.props.fallback) {
        return this.props.fallback;
      }
      
      // Default fallback
      return (
        <div className="error-boundary-fallback">
          <h2>Something went wrong</h2>
          <button onClick={this.reset}>Try again</button>
        </div>
      );
    }

    return this.props.children;
  }
}

Granular Boundary Placement

1. App-Level Boundary (Coarse)

// app/layout.tsx
import { ErrorBoundary } from '@/components/ErrorBoundary';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ErrorBoundary fallback={<CriticalError />}>
          {children}
        </ErrorBoundary>
      </body>
    </html>
  );
}

function CriticalError() {
  return (
    <div className="critical-error">
      <h1>Application Error</h1>
      <p>We're sorry, but something went wrong.</p>
      <button onClick={() => window.location.reload()}>
        Reload Application
      </button>
    </div>
  );
}

2. Page-Level Boundaries (Medium)

// app/dashboard/page.tsx
export default function Dashboard() {
  return (
    <ErrorBoundary 
      fallback={(error, reset) => (
        <PageError error={error} onReset={reset} />
      )}
    >
      <DashboardContent />
    </ErrorBoundary>
  );
}

3. Feature-Level Boundaries (Fine)

// components/Dashboard.tsx
function Dashboard() {
  return (
    <div className="dashboard">
      {/* Each feature is isolated */}
      <ErrorBoundary fallback={<ProfileError />}>
        <UserProfile />
      </ErrorBoundary>
      
      <ErrorBoundary fallback={<NotificationsError />}>
        <Notifications />
      </ErrorBoundary>
      
      <ErrorBoundary fallback={<AnalyticsError />}>
        <Analytics />
      </ErrorBoundary>
      
      <ErrorBoundary fallback={<ActivityError />}>
        <ActivityFeed />
      </ErrorBoundary>
    </div>
  );
}

4. Component-Level Boundaries (Very Fine)

// For critical individual components
function ProductList() {
  return (
    <div>
      {products.map(product => (
        <ErrorBoundary key={product.id} fallback={<ProductCardError />}>
          <ProductCard product={product} />
        </ErrorBoundary>
      ))}
    </div>
  );
}

Advanced Error Boundary Patterns

Named Boundaries with Context

// components/NamedErrorBoundary.tsx
interface NamedErrorBoundaryProps {
  name: string;
  children: ReactNode;
  fallback?: ReactNode;
}

export class NamedErrorBoundary extends Component<NamedErrorBoundaryProps, State> {
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    // Report with boundary name for better debugging
    reportError(error, {
      boundary: this.props.name,
      componentStack: errorInfo.componentStack,
    });
  }
  
  // ... rest of implementation
}

// Usage
<NamedErrorBoundary name="UserProfile">
  <UserProfile />
</NamedErrorBoundary>

Retry with Exponential Backoff

// components/RetryErrorBoundary.tsx
interface State {
  hasError: boolean;
  error: Error | null;
  retryCount: number;
}

export class RetryErrorBoundary extends Component<Props, State> {
  state = { hasError: false, error: null, retryCount: 0 };
  
  private maxRetries = 3;
  private retryDelay = 1000; // Start with 1s
  
  reset = () => {
    const { retryCount } = this.state;
    
    if (retryCount >= this.maxRetries) {
      console.error('Max retries reached');
      return;
    }
    
    // Exponential backoff: 1s, 2s, 4s
    const delay = this.retryDelay * Math.pow(2, retryCount);
    
    setTimeout(() => {
      this.setState(state => ({
        hasError: false,
        error: null,
        retryCount: state.retryCount + 1,
      }));
    }, delay);
  };
  
  render() {
    if (this.state.hasError) {
      const canRetry = this.state.retryCount < this.maxRetries;
      
      return (
        <div>
          <p>Error: {this.state.error?.message}</p>
          {canRetry ? (
            <button onClick={this.reset}>
              Retry ({this.maxRetries - this.state.retryCount} left)
            </button>
          ) : (
            <p>Maximum retries reached. Please refresh the page.</p>
          )}
        </div>
      );
    }
    
    return this.props.children;
  }
}

Conditional Boundaries

// Only wrap in error boundary in production
function ConditionalErrorBoundary({ children }: { children: ReactNode }) {
  if (process.env.NODE_ENV === 'development') {
    // In dev, let errors bubble for better DX
    return <>{children}</>;
  }
  
  // In production, catch errors
  return (
    <ErrorBoundary fallback={<ErrorFallback />}>
      {children}
    </ErrorBoundary>
  );
}

Cascading Boundaries

// Each level provides more specific fallbacks
function App() {
  return (
    {/* Level 1: App-wide fallback */}
    <ErrorBoundary fallback={<AppError />}>
      <Header />
      
      {/* Level 2: Page-level fallback */}
      <ErrorBoundary fallback={<PageError />}>
        <MainContent>
          {/* Level 3: Feature-level fallback */}
          <ErrorBoundary fallback={<FeatureError />}>
            <FeatureComponent />
          </ErrorBoundary>
        </MainContent>
      </ErrorBoundary>
      
      <Footer />
    </ErrorBoundary>
  );
}

React 19 Error Boundary Hook

// React 19 introduces useErrorBoundary hook
'use client';

import { useErrorBoundary } from 'react';

function MyComponent() {
  const [error, resetError] = useErrorBoundary();
  
  if (error) {
    return (
      <div>
        <h2>Error: {error.message}</h2>
        <button onClick={resetError}>Try again</button>
      </div>
    );
  }
  
  return <div>Normal content</div>;
}

Error Boundary + Suspense

// Combine error boundaries with Suspense for complete UX
function DataComponent() {
  return (
    <ErrorBoundary fallback={<ErrorUI />}>
      <Suspense fallback={<LoadingUI />}>
        <AsyncData />
      </Suspense>
    </ErrorBoundary>
  );
}

// This handles:
// - Loading state → LoadingUI
// - Error state → ErrorUI
// - Success state → AsyncData

Reusable Error Fallbacks

// components/ErrorFallbacks.tsx

export function FeatureUnavailable({ feature, onRetry }: Props) {
  return (
    <div className="error-fallback feature-unavailable">
      <Icon name="warning" />
      <h3>{feature} is temporarily unavailable</h3>
      <p>We're working on it. Please try again.</p>
      <button onClick={onRetry}>Retry</button>
    </div>
  );
}

export function PartialError({ message, children }: Props) {
  return (
    <div className="partial-error">
      <div className="error-banner">
        <Icon name="alert" />
        <span>{message}</span>
      </div>
      {children}
    </div>
  );
}

export function CriticalError() {
  return (
    <div className="critical-error">
      <Icon name="error" size="large" />
      <h1>Something went wrong</h1>
      <p>Please refresh the page or contact support if the problem persists.</p>
      <div className="actions">
        <button onClick={() => window.location.reload()}>
          Refresh Page
        </button>
        <a href="/">Go to Homepage</a>
        <a href="/support">Contact Support</a>
      </div>
    </div>
  );
}

Testing Error Boundaries

// components/__tests__/ErrorBoundary.test.tsx
import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from '../ErrorBoundary';

const ThrowError = () => {
  throw new Error('Test error');
};

describe('ErrorBoundary', () => {
  it('catches errors and shows fallback', () => {
    // Suppress console.error for this test
    const spy = jest.spyOn(console, 'error').mockImplementation();
    
    render(
      <ErrorBoundary fallback={<div>Error occurred</div>}>
        <ThrowError />
      </ErrorBoundary>
    );
    
    expect(screen.getByText('Error occurred')).toBeInTheDocument();
    
    spy.mockRestore();
  });
  
  it('resets error when reset is called', () => {
    const spy = jest.spyOn(console, 'error').mockImplementation();
    
    const { rerender } = render(
      <ErrorBoundary>
        <ThrowError />
      </ErrorBoundary>
    );
    
    // Error is shown
    expect(screen.getByText('Something went wrong')).toBeInTheDocument();
    
    // Click reset
    screen.getByText('Try again').click();
    
    // Error is cleared
    rerender(
      <ErrorBoundary>
        <div>Success</div>
      </ErrorBoundary>
    );
    
    expect(screen.getByText('Success')).toBeInTheDocument();
    
    spy.mockRestore();
  });
});

Boundary Placement Strategy

LevelGranularityUse CaseFallback
AppCoarseCritical errorsFull page error
RouteMediumPage-level issuesPage error
FeatureFineFeature failuresFeature unavailable
ComponentVery FineIndividual componentsComponent error

Best Practices

  1. Multiple Layers: Use cascading boundaries for different failure zones
  2. Named Boundaries: Add names for better debugging
  3. Specific Fallbacks: Show relevant fallback UI for each boundary
  4. Enable Recovery: Provide reset/retry mechanisms
  5. Report Errors: Always log to monitoring service
  6. Test Boundaries: Include error scenarios in tests
  7. Don't Overuse: Too many boundaries = degraded UX

Common Mistakes

Single app-level boundary: One error crashes everything
Multiple strategic boundaries

No reset mechanism: Users stuck in error state
Provide retry/reset options

Generic "Something went wrong": Not helpful
Contextual error messages

Catching everything: Hides real issues
Let dev errors surface in development

When to Use Error Boundaries

Use error boundaries for:

  • Isolating features/sections
  • Third-party components
  • Complex data visualization
  • User-generated content rendering
  • API-dependent components

Don't use for:

  • Event handlers (use try-catch)
  • Async operations (use error states)
  • Every single component (overkill)
  • Server components (use error.tsx)

Error boundaries are your safety net—place them strategically to catch failures without degrading the entire user experience.

On this page