React Error Boundaries: Graceful Failure Handling

React Error Boundaries: Graceful Failure Handling

Every React app will eventually encounter a runtime error — a failed API call, an unexpected null value, a third-party component that throws. Without any protection in place, a single unhandled error in one component will crash your entire application, leaving the user staring at a blank white screen with no explanation.

Error Boundaries are React's answer to this problem. They let you catch errors in any part of your component tree and render a fallback UI instead of crashing the whole app. They're one of the most underrated features in React — widely supported, production-ready, and barely used by most developers. This guide will change that.

What is an Error Boundary — and Why Does It Exist?

In a standard JavaScript application, you'd wrap risky code in a try/catch block to prevent errors from propagating. But React's rendering model doesn't work that way. When React renders your component tree, it calls your component functions recursively. If an error is thrown inside a component during rendering, React has no built-in way to catch it and continue — the error bubbles all the way up and unmounts the entire tree.

React introduced Error Boundaries to solve this. An Error Boundary is a component that wraps part of your tree and acts as an error catching layer. If any component inside it throws during rendering, during a lifecycle method, or inside a constructor, the Error Boundary intercepts the error, prevents it from propagating upward, and renders a fallback UI in place of the crashed subtree.

The key word there is "subtree". Error Boundaries are surgical — they catch errors only in the components they wrap, leaving the rest of your application completely unaffected. If a single widget on a dashboard page throws an error, the Error Boundary surrounding that widget catches it and renders a friendly message. The navbar, the sidebar, the other widgets — they all continue working normally.

Think of an Error Boundary like a circuit breaker in your home. When one circuit overloads and trips, it cuts power only to that circuit — the rest of the house stays on. Without it, a single fault could take down the entire building.

Error Boundaries vs try/catch — Why You Can't Just Use try/catch

This is the most common misconception developers have when they first hear about Error Boundaries. "Why do I need this? I'll just wrap things in try/catch." The problem is that try/catch is fundamentally incompatible with how React's rendering works.

try/catch only works for imperative code — code you write and execute in sequence. When you call a function, you can wrap it. But React's rendering is declarative. React decides when to call your component functions, not you. When React calls your component and that component throws, the error is happening inside React's own internals — outside the scope of any try/catch you could write in your own code.

There's also a timing problem. Even if you try to wrap a component's return statement in try/catch, by the time the error surfaces, React may already be mid-way through reconciling the tree. A try/catch won't cleanly restore the previous render — React needs a dedicated mechanism for this.

  • try/catch works for event handlers and async code — use it there as normal
  • try/catch cannot catch errors that occur during React rendering, in lifecycle methods, or in constructors
  • Error Boundaries catch errors during rendering, lifecycle methods, and constructors of the whole tree below them
  • Error Boundaries do NOT catch errors in event handlers, async code (setTimeout, fetch), or server-side rendering — handle those with try/catch as usual
The rule: use try/catch for your event handlers and async functions. Use Error Boundaries for your component tree. They're complementary, not competing.

A Brief Note on Class-Based Error Boundaries

Historically, implementing an Error Boundary required writing a class component — one of the very few things in modern React that still does. React exposes two lifecycle methods — static getDerivedStateFromError() and componentDidCatch() — that only work in class components and form the foundation of how Error Boundaries function under the hood.

Writing a class-based Error Boundary from scratch means boilerplate: a class, a constructor, state for tracking whether an error occurred, the two lifecycle methods, and a render method with a conditional fallback. It works, but it's repetitive code you'll copy into every project.

The React team has acknowledged this and a hook-based API for Error Boundaries is on the long-term roadmap. In the meantime, the community solution is the react-error-boundary library — a thin, well-maintained wrapper around the class implementation that exposes a clean, modern API. It's what most production React apps use today, and it's what we'll use for the rest of this guide.

Getting Started with react-error-boundary

Install the library:

bash
npm install react-error-boundary

The library's main export is the ErrorBoundary component. You wrap it around any part of your tree you want to protect, and pass it a fallback to render when something goes wrong. Let's start with the simplest possible usage before moving to the real-world example.

jsx
// Basic usage
import { ErrorBoundary } from "react-error-boundary";

function App() {
  return (
    <ErrorBoundary fallback={<p>Something went wrong.</p>}>
      <MyComponent />
    </ErrorBoundary>
  );
}

If MyComponent (or anything inside it) throws during rendering, the ErrorBoundary catches it and renders the fallback paragraph instead. The rest of your app is unaffected.

Building a Proper Fallback Component

A plain paragraph is fine for a quick test, but in a real app you want a fallback that's helpful to the user and gives them a way to recover. The react-error-boundary library passes two props to your fallback component: error (the actual Error object that was thrown) and resetErrorBoundary (a function that resets the boundary and retries rendering the original component).

javascript
// src/components/ErrorFallback.jsx
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="error-fallback">
      <h2>Something went wrong</h2>
      <p className="error-message">{error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

export default ErrorFallback;

Now use this as your fallbackRender. Note the difference between fallback (a static element) and FallbackComponent (a component that receives props) — use FallbackComponent when you need access to the error or the reset function:

jsx
import { ErrorBoundary } from "react-error-boundary";
import ErrorFallback from "./components/ErrorFallback";

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <MyComponent />
    </ErrorBoundary>
  );
}

Real-World Example: A Broken API Fetch Component

Let's build a realistic scenario. We have a UserProfile component that fetches a user from an API. Sometimes the API returns malformed data, the endpoint is down, or the response is missing a field our component tries to access. Without an Error Boundary, any of these cases crashes the whole page.

First, the component. Notice how we deliberately throw an error if the API response is missing the user data — this simulates a bad API response:

jsx
// src/components/UserProfile.jsx
import { useState, useEffect } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error(`API error: ${res.status}`);
        return res.json();
      })
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        // Re-throw so the Error Boundary can catch it
        // Note: async errors in useEffect need to be re-thrown
        // via setState to be caught by Error Boundaries
        setLoading(false);
        throw err;
      });
  }, [userId]);

  if (loading) return <p>Loading user...</p>;

  // Simulate bad data — accessing a property that doesn't exist
  // will throw a TypeError that the Error Boundary will catch
  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.address.city}</p>
    </div>
  );
}

export default UserProfile;
Important: Error Boundaries do not catch errors thrown inside useEffect or async functions directly — those happen outside the render cycle. To bridge this gap, react-error-boundary provides a useErrorBoundary hook that lets you manually trigger the boundary from async code. See the next step.

Triggering the Error Boundary from Async Code

Since fetch() is async, any error it throws won't be caught by the Error Boundary automatically. The react-error-boundary library solves this cleanly with the useErrorBoundary hook, which gives you a showBoundary function you can call from inside a catch block:

jsx
// src/components/UserProfile.jsx (updated)
import { useState, useEffect } from "react";
import { useErrorBoundary } from "react-error-boundary";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const { showBoundary } = useErrorBoundary();

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error(`Failed to load user (status ${res.status})`);
        return res.json();
      })
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        // Hand the error to the Error Boundary instead of swallowing it
        showBoundary(err);
      });
  }, [userId]);

  if (loading) return <p>Loading user...</p>;
  if (!user) return null;

  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>{user.address.city}</p>
    </div>
  );
}

export default UserProfile;

Now wire it all together. We wrap UserProfile in an ErrorBoundary with our ErrorFallback. We also pass an onReset callback that controls what happens when the user clicks "Try again" — in this case, we could reset a key to force a fresh fetch:

jsx
// src/App.jsx
import { useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import UserProfile from "./components/UserProfile";
import ErrorFallback from "./components/ErrorFallback";

function App() {
  const [userId, setUserId] = useState(1);

  return (
    <div className="app">
      <h1>User Dashboard</h1>

      {/* The Error Boundary only wraps UserProfile — the rest of the page is safe */}
      <ErrorBoundary
        FallbackComponent={ErrorFallback}
        onReset={() => setUserId(1)} // Reset state when user clicks "Try again"
        resetKeys={[userId]}         // Auto-reset if userId changes
      >
        <UserProfile userId={userId} />
      </ErrorBoundary>

      {/* These buttons are outside the boundary — they keep working even if UserProfile crashes */}
      <div className="controls">
        <button onClick={() => setUserId(1)}>Load User 1</button>
        <button onClick={() => setUserId(2)}>Load User 2</button>
        <button onClick={() => setUserId(999)}>Load Bad User (triggers error)</button>
      </div>
    </div>
  );
}

export default App;

Notice the resetKeys prop. This tells the ErrorBoundary to automatically reset and retry whenever userId changes. So if a user triggers an error on a bad ID, clicking "Load User 1" resets the boundary and tries the fetch again — no manual intervention needed.

Where to Place Error Boundaries in Your App

Placement is everything with Error Boundaries. Put them too high and a single crash takes out too much UI. Put them too low and you end up with excessive wrapping that's hard to maintain. Here's a practical framework:

  • Top-level boundary — wrap your entire app in one ErrorBoundary as a last resort. This prevents the blank white screen in the absolute worst case. Use a simple fallback like a 'Something went wrong, please refresh' message.
  • Route-level boundaries — wrap each page/route in its own ErrorBoundary. If the Dashboard page crashes, the Settings page is unaffected. This is the most important placement for most apps.
  • Widget-level boundaries — wrap individual self-contained components like charts, data tables, or third-party widgets. These are the most isolated and give the best user experience when something fails.
  • Third-party components — always wrap components from external libraries you don't control. If their code throws, your app stays alive.

A common production pattern is three layers: one at the app root, one per route, and one around any complex or data-dependent widget. This gives you full coverage without over-engineering.

jsx
// Three-layer Error Boundary pattern
function App() {
  return (
    // Layer 1: App-level last resort
    <ErrorBoundary FallbackComponent={AppErrorFallback}>
      <Navbar />
      <Routes>
        {/* Layer 2: Route-level boundary */}
        <Route
          path="/dashboard"
          element={
            <ErrorBoundary FallbackComponent={PageErrorFallback}>
              <Dashboard />
            </ErrorBoundary>
          }
        />
      </Routes>
      <Footer />
    </ErrorBoundary>
  );
}

function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Layer 3: Widget-level boundary */}
      <ErrorBoundary FallbackComponent={WidgetErrorFallback}>
        <RevenueChart />
      </ErrorBoundary>
      <ErrorBoundary FallbackComponent={WidgetErrorFallback}>
        <UserProfile userId={1} />
      </ErrorBoundary>
    </div>
  );
}

Logging Errors for Monitoring

Catching an error gracefully is only half the job — you also need to know it happened. The ErrorBoundary component accepts an onError callback that fires whenever an error is caught, giving you the error object and a component stack trace. This is where you'd hook in a monitoring service like Sentry:

jsx
<ErrorBoundary
  FallbackComponent={ErrorFallback}
  onError={(error, info) => {
    // info.componentStack shows you exactly which component threw
    console.error("Caught by ErrorBoundary:", error, info.componentStack);

    // Send to your monitoring service
    // Sentry.captureException(error, { extra: info });
  }}
>
  <UserProfile userId={userId} />
</ErrorBoundary>

Final Thoughts

Error Boundaries are one of those features that seem optional until the moment you need them — and by then it's too late. Adding them proactively takes minutes, costs nothing in performance, and is the difference between a user seeing a helpful message and a user seeing a blank white page and closing your tab.

The react-error-boundary library makes the implementation straightforward enough that there's no good reason not to use them. A top-level boundary takes five lines of code. Route-level boundaries add a few more. The protection they provide is completely disproportionate to the effort involved.

Want to see how to integrate Error Boundaries with Sentry for full production error monitoring, or how to use them alongside React Query's error states? Drop a comment in LiveChat and I'll cover it in a follow-up guide.