React.js Fundamentals

Error Boundaries & Suspense

15 min Lesson 20 of 40

Introduction to Error Handling in React

React provides powerful mechanisms for handling errors gracefully: Error Boundaries catch JavaScript errors in component trees, while Suspense manages asynchronous operations and loading states. Together, they create resilient user experiences.

Error Boundaries: Catching Component Errors

Error Boundaries are React components that catch JavaScript errors anywhere in their child component tree, log errors, and display a fallback UI. They work like JavaScript try/catch blocks but for components.

Important: Error Boundaries must be class components. Functional components cannot be Error Boundaries (yet), but they can be wrapped by them.

// src/components/ErrorBoundary.jsx
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      errorInfo: null
    };
  }

  // Called when an error is thrown during rendering
  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI
    return { hasError: true };
  }

  // Called after an error has been thrown
  componentDidCatch(error, errorInfo) {
    // Log error to error reporting service
    console.error('Error caught by boundary:', error, errorInfo);

    // You can log to services like Sentry, LogRocket, etc.
    // logErrorToService(error, errorInfo);

    this.setState({
      error,
      errorInfo
    });
  }

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

  render() {
    if (this.state.hasError) {
      // Fallback UI
      return (
        <div style={{
          padding: '2rem',
          backgroundColor: '#fee',
          border: '1px solid #fcc',
          borderRadius: '0.5rem'
        }}>
          <h2>Something went wrong</h2>
          <details style={{ whiteSpace: 'pre-wrap' }}>
            <summary>Error Details</summary>
            <p>{this.state.error && this.state.error.toString()}</p>
            <p>{this.state.errorInfo && this.state.errorInfo.componentStack}</p>
          </details>
          <button onClick={this.resetError}>Try Again</button>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

Using Error Boundaries

Wrap components that might throw errors with the Error Boundary:

import ErrorBoundary from './components/ErrorBoundary';
import UserProfile from './components/UserProfile';
import Dashboard from './components/Dashboard';

function App() {
  return (
    <div>
      <h1>My App</h1>

      {/* Wrap individual components */}
      <ErrorBoundary>
        <UserProfile userId={123} />
      </ErrorBoundary>

      {/* Or wrap entire sections */}
      <ErrorBoundary>
        <Dashboard>
          <Stats />
          <Charts />
          <RecentActivity />
        </Dashboard>
      </ErrorBoundary>
    </div>
  );
}

// Component that might throw an error
function BuggyComponent({ user }) {
  if (!user) {
    throw new Error('User is required!');
  }

  return <div>{user.name}</div>;
}

Error Boundaries Do NOT Catch:

  • Errors in event handlers (use try/catch)
  • Asynchronous code (setTimeout, promises)
  • Server-side rendering errors
  • Errors thrown in the Error Boundary itself

Advanced Error Boundary with Logging

Create a production-ready Error Boundary with error tracking:

import React from 'react';

class ProductionErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      errorInfo: null,
      eventId: null
    };
  }

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

  componentDidCatch(error, errorInfo) {
    // Log to external service in production
    if (process.env.NODE_ENV === 'production') {
      // Example: Sentry integration
      // const eventId = Sentry.captureException(error, {
      //   contexts: { react: { componentStack: errorInfo.componentStack } }
      // });
      // this.setState({ eventId });

      // Or your custom logging service
      fetch('/api/log-error', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          error: error.toString(),
          componentStack: errorInfo.componentStack,
          timestamp: new Date().toISOString(),
          userAgent: navigator.userAgent,
          url: window.location.href
        })
      });
    }

    this.setState({ error, errorInfo });
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-container">
          <h2>Oops! Something went wrong</h2>
          <p>We've been notified and are working on a fix.</p>

          {process.env.NODE_ENV === 'development' && (
            <details>
              <summary>Error Details (Development Only)</summary>
              <pre>{this.state.error.toString()}</pre>
              <pre>{this.state.errorInfo.componentStack}</pre>
            </details>
          )}

          <button onClick={() => window.location.reload()}>
            Reload Page
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Suspense: Managing Async Operations

Suspense lets components "wait" for something before rendering, showing fallback UI during loading:

import { Suspense, lazy } from 'react';

// Lazy load components
const HeavyComponent = lazy(() => import('./components/HeavyComponent'));
const Dashboard = lazy(() => import('./components/Dashboard'));
const UserProfile = lazy(() => import('./components/UserProfile'));

function App() {
  return (
    <div>
      {/* Basic Suspense with loading fallback */}
      <Suspense fallback={<div>Loading...</div>}>
        <HeavyComponent />
      </Suspense>

      {/* Multiple components in one Suspense */}
      <Suspense fallback={<LoadingSpinner />}>
        <Dashboard />
        <UserProfile userId={123} />
      </Suspense>

      {/* Nested Suspense boundaries */}
      <Suspense fallback={<div>Loading page...</div>}>
        <MainLayout>
          <Suspense fallback={<div>Loading sidebar...</div>}>
            <Sidebar />
          </Suspense>
          <Suspense fallback={<div>Loading content...</div>}>
            <Content />
          </Suspense>
        </MainLayout>
      </Suspense>
    </div>
  );
}

// Custom loading component
function LoadingSpinner() {
  return (
    <div style={{
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
      height: '200px'
    }}>
      <div className="spinner">Loading...</div>
    </div>
  );
}

Combining Error Boundaries and Suspense

Use both together for robust error handling and loading states:

import { Suspense, lazy } from 'react';
import ErrorBoundary from './components/ErrorBoundary';

const AsyncComponent = lazy(() => import('./components/AsyncComponent'));

function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<LoadingFallback />}>
        <AsyncComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

// Better: Separate loading and error states
function RobustAsyncLoader({ children }) {
  return (
    <ErrorBoundary fallback={<ErrorFallback />}>
      <Suspense fallback={<LoadingFallback />}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

// Usage
function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>

      <RobustAsyncLoader>
        <AsyncCharts />
      </RobustAsyncLoader>

      <RobustAsyncLoader>
        <AsyncUserList />
      </RobustAsyncLoader>
    </div>
  );
}

Skeleton Loading UI

Create better loading experiences with skeleton screens:

// Skeleton component for loading state
function UserCardSkeleton() {
  return (
    <div className="user-card skeleton">
      <div className="skeleton-avatar"></div>
      <div className="skeleton-text"></div>
      <div className="skeleton-text short"></div>
    </div>
  );
}

/* Skeleton CSS */
.skeleton {
  animation: pulse 1.5s ease-in-out infinite;
}

.skeleton-avatar {
  width: 60px;
  height: 60px;
  border-radius: 50%;
  background: #e0e0e0;
}

.skeleton-text {
  height: 16px;
  background: #e0e0e0;
  border-radius: 4px;
  margin: 8px 0;
}

.skeleton-text.short {
  width: 60%;
}

@keyframes pulse {
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
}

// Usage with Suspense
function UserList() {
  return (
    <Suspense fallback={
      <div>
        <UserCardSkeleton />
        <UserCardSkeleton />
        <UserCardSkeleton />
      </div>
    }>
      <AsyncUserList />
    </Suspense>
  );
}

Error Recovery Strategies

Implement different recovery strategies based on error types:

import React from 'react';

class SmartErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hasError: false,
      error: null,
      retryCount: 0
    };
  }

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

  componentDidCatch(error, errorInfo) {
    console.error('Error:', error, errorInfo);
  }

  handleRetry = () => {
    this.setState(prevState => ({
      hasError: false,
      error: null,
      retryCount: prevState.retryCount + 1
    }));
  };

  handleReload = () => {
    window.location.reload();
  };

  handleGoHome = () => {
    window.location.href = '/';
  };

  render() {
    if (this.state.hasError) {
      const { error, retryCount } = this.state;
      const isNetworkError = error.message.includes('network') ||
                             error.message.includes('fetch');

      return (
        <div className="error-recovery">
          <h2>Something went wrong</h2>

          {isNetworkError && (
            <>
              <p>Network connection issue detected.</p>
              <button onClick={this.handleRetry}>
                Retry ({3 - retryCount} attempts remaining)
              </button>
            </>
          )}

          {!isNetworkError && (
            <>
              <p>An unexpected error occurred.</p>
              <button onClick={this.handleReload}>Reload Page</button>
              <button onClick={this.handleGoHome}>Go to Home</button>
            </>
          )}

          {retryCount >= 3 && (
            <p>
              If the problem persists, please contact support.
            </p>
          )}
        </div>
      );
    }

    return this.props.children;
  }
}

Best Practice: Place Error Boundaries strategically at different levels—one at the app root for catastrophic errors, and others around features for isolated error handling.

Concurrent Features with Suspense

React 18+ Suspense works with concurrent features for better UX:

import { Suspense, useState, useTransition } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e) => {
    const value = e.target.value;

    // Mark state update as non-urgent
    startTransition(() => {
      setQuery(value);
    });
  };

  return (
    <div>
      <input
        type="text"
        placeholder="Search..."
        onChange={handleSearch}
        style={{ opacity: isPending ? 0.5 : 1 }}
      />

      <Suspense fallback={<div>Searching...</div>}>
        <ResultsList query={query} />
      </Suspense>
    </div>
  );
}

Exercise 1: Error Boundary System

Task: Build a comprehensive error handling system:

  • Create an Error Boundary component with custom fallback UI
  • Add error logging to console (simulate external service)
  • Implement retry functionality
  • Create a buggy component that throws errors on button click
  • Show different error messages for different error types

Exercise 2: Lazy Loading Dashboard

Task: Build a dashboard with lazy-loaded sections:

  • Create separate components for Charts, Stats, and Activity sections
  • Lazy load each section with React.lazy()
  • Add Suspense boundaries with skeleton loading UI
  • Wrap each section in Error Boundaries
  • Add a "Reload Section" button in error fallback

Exercise 3: Robust Async Data Loader

Task: Create a reusable async data loader component:

  • Combine Error Boundary and Suspense
  • Accept render props for loading, error, and success states
  • Implement automatic retry with exponential backoff
  • Add timeout handling
  • Display retry count and estimated next retry time

Summary

In this lesson, you mastered error handling and async management in React:

  • Creating Error Boundary components to catch and handle errors gracefully
  • Understanding what Error Boundaries can and cannot catch
  • Implementing production-ready error logging and tracking
  • Using Suspense for lazy loading and async operations
  • Combining Error Boundaries and Suspense for robust applications
  • Building skeleton loading UIs for better user experience
  • Implementing smart error recovery strategies
  • Leveraging concurrent features for optimal performance

With these React.js fundamentals mastered, you're ready to build production-quality applications with proper error handling, optimized performance, and excellent user experience!