The Problem: One Bad Component Spoils the Bunch
By default, a JavaScript error in any part of your React component tree will cause the entire application to unmount and show a blank white screen. This is a terrible user experience. Imagine an error in a small, non-critical widget taking down your whole checkout page!
Error Boundaries are special React components that solve this problem. They catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed.
How to Create an Error Boundary
Error Boundaries are unique because they must be class components. They require access to two special lifecycle methods that are not yet available as hooks:
- static getDerivedStateFromError(): This is called during the "render" phase after a descendant component throws an error. It lets you update the state to render a fallback UI.
- componentDidCatch(): This is called during the "commit" phase. It's used for side effects, like logging the error to an external service (e.g., Sentry, LogRocket).
Here is a reusable ErrorBoundary component:
JavaScript
import React from 'react';
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    // The `hasError` state determines whether to show the fallback UI
    this.state = { hasError: false, error: null };
  }
  // 1. Update state so the next render will show the fallback UI.
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  // 2. Log the error to an error reporting service
  componentDidCatch(error, errorInfo) {
    console.error("Uncaught error:", error, errorInfo);
    // You would typically log this to a service like Sentry
    // logErrorToMyService(error, errorInfo);
  }
  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return this.props.fallback || <h1>Something went wrong. Please try again.</h1>;
    }
    // If there's no error, render the children as normal
    return this.props.children;
  }
}
export default ErrorBoundary;
Using Your Error Boundary
Using it is simple: just wrap it around any part of your application you want to protect. You can place them at various levels of granularity.
JavaScript
import ErrorBoundary from './ErrorBoundary';
import UserProfile from './UserProfile'; // A component that might throw an error
import NewsFeed from './NewsFeed'; // Another component that might fail
function App() {
  return (
    <div>
      <nav>My App</nav>
      <main>
        <ErrorBoundary fallback={<p>⚠️ Could not load user profile.</p>}>
          <UserProfile />
        </ErrorBoundary>
        <ErrorBoundary fallback={<p>⚠️ Could not load news feed.</p>}>
          <NewsFeed />
        </ErrorBoundary>
      </main>
    </div>
  );
}
Now, if UserProfile crashes, the NewsFeed will continue to function correctly, and the user will see the fallback message instead of a blank screen.
Important: Error Boundaries do not catch errors in:
- Event handlers (use a standard try...catch block)
- Asynchronous code (setTimeout, requestAnimationFrame)
- Server-side rendering
- The Error Boundary component itself
Handling Asynchronous Rendering States
A common source of errors is data fetching. An async operation has three primary states you need to handle in the UI: loading, success (data), and error. Managing these states explicitly prevents bugs and improves the user experience.
Here’s a common pattern using the useState and useEffect hooks:
JavaScript
import { useState, useEffect } from 'react';
function UserData({ userId }) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  useEffect(() => {
    async function fetchData() {
      setIsLoading(true);
      setError(null);
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (e) {
        setError(e);
      } finally {
        setIsLoading(false);
      }
    }
    fetchData();
  }, [userId]); // Re-run effect if userId changes
  if (isLoading) {
    return <p>Loading user data...</p>;
  }
  if (error) {
    return <p>Error fetching data: {error.message}</p>;
  }
  return (
    <div>
      <h1>{data.name}</h1>
      <p>Email: {data.email}</p>
    </div>
  );
}
This component provides clear feedback to the user at every stage of the data fetching process.