TechLead

Error Boundaries & Suspense

Error handling, lazy loading, and Suspense for data fetching in React

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 instead of crashing the whole app. They work like a try/catch block, but for components.

Without error boundaries, a single error in one component can take down your entire React application with a white screen. Error boundaries prevent this by isolating failures to specific sections of your UI.

⚠️ Important Limitations

Error boundaries do not catch errors in:

  • Event handlers (use regular try/catch instead)
  • Asynchronous code (setTimeout, requestAnimationFrame)
  • Server-side rendering
  • Errors thrown in the error boundary itself

Creating an Error Boundary (Class Component)

Error boundaries must be class components — functional components cannot catch render errors (yet). Here's the standard pattern:

import React from 'react';

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

  // Update state so the next render shows the fallback UI
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  // Log the error for reporting
  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error);
    console.error('Component stack:', errorInfo.componentStack);
    // Send to error reporting service
    // logErrorToService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Render fallback UI
      return (
        <div className="p-6 bg-red-50 border border-red-200 rounded-lg">
          <h2 className="text-red-800 font-bold mb-2">Something went wrong</h2>
          <p className="text-red-600 mb-4">{this.state.error?.message}</p>
          <button
            onClick={() => this.setState({ hasError: false, error: null })}
            className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
          >
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

Using Error Boundaries

Wrap components that might fail with the error boundary:

import ErrorBoundary from './ErrorBoundary';
import Dashboard from './Dashboard';
import Sidebar from './Sidebar';

function App() {
  return (
    <div className="flex">
      {/* Each section has its own error boundary */}
      <ErrorBoundary>
        <Sidebar />
      </ErrorBoundary>

      <ErrorBoundary>
        <Dashboard />
      </ErrorBoundary>
    </div>
  );
}

// A component that might crash
function Dashboard() {
  const data = useApiData(); // might throw
  return <div>{data.map(item => <Card key={item.id} {...item} />)}</div>;
}

Reusable Error Boundary with Custom Fallback

Make the error boundary flexible by accepting a fallback prop:

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

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

  componentDidCatch(error, errorInfo) {
    this.props.onError?.(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Use custom fallback if provided
      if (this.props.fallback) {
        return this.props.fallback(this.state.error, () =>
          this.setState({ hasError: false, error: null })
        );
      }
      return <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

// Usage with custom fallback
<ErrorBoundary
  fallback={(error, retry) => (
    <div className="text-center p-8">
      <p className="text-red-600">Failed to load widget</p>
      <button onClick={retry}>Retry</button>
    </div>
  )}
>
  <WeatherWidget />
</ErrorBoundary>

React.lazy and Code Splitting

React.lazy() lets you load components on demand. Instead of bundling everything upfront, you split your code into smaller chunks that are loaded only when needed. This dramatically improves initial load time.

import React, { lazy } from 'react';

// Instead of:
// import HeavyChart from './HeavyChart';

// Load only when rendered:
const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminPanel = lazy(() => import('./AdminPanel'));
const UserProfile = lazy(() => import('./UserProfile'));

// Each lazy component creates a separate JS chunk
// that's downloaded only when the component is needed

Suspense for Loading States

Suspense lets you display a fallback while waiting for lazy-loaded components (or data in React 19+) to load. It's the companion to React.lazy().

import React, { Suspense, lazy, useState } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <h1>Dashboard</h1>

      {/* Suspense wraps lazy-loaded components */}
      <Suspense fallback={<LoadingSpinner />}>
        <DataTable />
      </Suspense>

      <button onClick={() => setShowChart(true)}>Show Chart</button>

      {showChart && (
        <Suspense fallback={<p>Loading chart...</p>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
}

function LoadingSpinner() {
  return (
    <div className="flex items-center justify-center p-8">
      <div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
    </div>
  );
}

Nested Suspense Boundaries

You can nest Suspense boundaries for granular loading states — each section loads independently:

function App() {
  return (
    <Suspense fallback={<AppSkeleton />}>
      <Header />

      <div className="flex">
        {/* Sidebar loads independently */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>

        <main>
          {/* Main content loads independently */}
          <Suspense fallback={<ContentSkeleton />}>
            <MainContent />
          </Suspense>
        </main>
      </div>
    </Suspense>
  );
}

// Skeleton components for smooth loading
function SidebarSkeleton() {
  return (
    <div className="w-64 p-4 space-y-3">
      {[...Array(6)].map((_, i) => (
        <div key={i} className="h-8 bg-gray-200 rounded animate-pulse" />
      ))}
    </div>
  );
}

Route-Based Code Splitting

The most common use case — split code by route so users only download the page they visit:

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Suspense, lazy } from 'react';

// Each route is a separate chunk
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Combining Error Boundaries + Suspense

In production, always combine both for robust error handling and loading states:

function AsyncBoundary({ children, fallback, errorFallback }) {
  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense fallback={fallback}>
        {children}
      </Suspense>
    </ErrorBoundary>
  );
}

// Usage
function App() {
  return (
    <AsyncBoundary
      fallback={<LoadingSpinner />}
      errorFallback={(error, retry) => (
        <div>
          <p>Error: {error.message}</p>
          <button onClick={retry}>Retry</button>
        </div>
      )}
    >
      <Dashboard />
    </AsyncBoundary>
  );
}

✅ Best Practices

  • • Place error boundaries at meaningful UI boundaries (sidebar, main content, widgets)
  • • Always provide skeleton/placeholder fallbacks in Suspense, not just spinners
  • • Use route-based code splitting as a minimum optimization
  • • Log errors to an external service (Sentry, LogRocket) in production
  • • Give users a way to retry or recover from errors
  • • Consider libraries like react-error-boundary for a more ergonomic API