Skip to content
elephantoo

Error boundaries

Lesson 20 of 30 12 min read

Catch rendering errors, show fallback UI, reset after errors and report them.


Bugs happen. An API returns something unexpected, a value is undefined where you expected an object, and suddenly a component throws. By default, a rendering error anywhere unmounts your whole React app — the user sees a blank white page. Error boundaries catch those errors and show a fallback UI for just the broken part, so the rest of the app keeps working.

Seeing the problem#

JSX
function UserCard({ user }) {
  return <h2>{user.profile.name}</h2>; // 💥 if `profile` is missing
}

function App() {
  return (
    <>
      <Header />
      <UserCard user={{ id: 1 }} />
      <Footer />
    </>
  );
}
Output
Uncaught TypeError: Cannot read properties of undefined (reading 'name')

Header and Footer vanish too. React does this on purpose: showing a half-broken UI could be worse than showing nothing (imagine a banking app displaying the wrong balance). But we can do better than a blank page.

Writing an error boundary#

Error boundaries are one of the few things that still need a class component, because they use two class-only lifecycle methods:

JSX
import { Component } from "react";

export class ErrorBoundary extends Component {
  state = { error: null };

  // 1. Called during render when a child throws: return new state to show the fallback
  static getDerivedStateFromError(error) {
    return { error };
  }

  // 2. Called after the error is caught: a good place to log it
  componentDidCatch(error, info) {
    console.error("Caught by boundary:", error, info.componentStack);
    // reportToService(error, info.componentStack);
  }

  render() {
    if (this.state.error) {
      return (
        this.props.fallback ?? (
          <div role="alert">
            <p>Something went wrong.</p>
            <button onClick={() => this.setState({ error: null })}>Try again</button>
          </div>
        )
      );
    }
    return this.props.children;
  }
}

Use it like any component:

JSX
function App() {
  return (
    <>
      <Header />
      <ErrorBoundary fallback={<p>Couldn’t load this profile.</p>}>
        <UserCard user={{ id: 1 }} />
      </ErrorBoundary>
      <Footer />
    </>
  );
}

Now only the profile area shows the fallback; the header and footer stay put.

The react-error-boundary package#

You'll rarely write the class yourself. The popular react-error-boundary package gives you a flexible, well-tested boundary:

Terminal
npm install react-error-boundary
JSX
import { ErrorBoundary, getErrorMessage } from "react-error-boundary";

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" className="error-box">
      <p>Something went wrong:</p>
      <pre>{getErrorMessage(error)}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

export default function Dashboard() {
  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onError={(error, info) => logError(error, info.componentStack)}
      onReset={() => {
        // reset any state that caused the error, e.g. clear a cache
      }}
    >
      <SalesChart />
    </ErrorBoundary>
  );
}

There are three ways to render the fallback:

  • fallback={<p>Oops</p>} — static JSX.
  • FallbackComponent={ErrorFallback} — a component receiving { error, resetErrorBoundary }.
  • fallbackRender={({ error, resetErrorBoundary }) => ...} — an inline render function.

error can be anything that was thrown (not always an Error), so the package's getErrorMessage(error) helper safely extracts a message.

Resetting automatically with resetKeys#

If the error came from specific data, moving to different data should clear it:

JSX
function ProfilePage({ userId }) {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback} resetKeys={[userId]}>
      <UserProfile userId={userId} />
    </ErrorBoundary>
  );
}

When userId changes, the boundary resets and tries rendering the new profile. (Giving the boundary key={userId} has a similar effect, but also remounts the children.)

What error boundaries catch — and what they don't#

Boundaries catch errors thrown while React is rendering the tree below them:

  • ✅ During rendering (user.profile.name on undefined)
  • ✅ In lifecycle methods and effects (useEffect, useLayoutEffect)
  • ✅ In constructors of child class components
  • ✅ Errors from rejected promises read with use() or thrown by Suspense-enabled data libraries
  • ✅ Errors thrown inside a startTransition Action (React 19)

They do not catch errors:

  • ❌ In event handlers (onClick) — these happen outside rendering
  • ❌ In async code (setTimeout, a .then callback that isn't part of rendering)
  • ❌ During server-side rendering
  • ❌ Inside the error boundary itself (a parent boundary handles those)

Errors in event handlers and async code#

For event handlers, a regular try/catch and some state is usually best:

JSX
function SaveButton({ onSave }) {
  const [error, setError] = useState(null);

  async function handleClick() {
    setError(null);
    try {
      await onSave();
    } catch (err) {
      setError(err instanceof Error ? err.message : "Save failed");
    }
  }

  return (
    <>
      <button onClick={handleClick}>Save</button>
      {error && <p role="alert">{error}</p>}
    </>
  );
}

If the error should show the boundary's fallback instead, hand it over with useErrorBoundary:

JSX
import { useErrorBoundary } from "react-error-boundary";

function Report({ id }) {
  const { showBoundary } = useErrorBoundary();

  async function handleExport() {
    try {
      await exportReport(id);
    } catch (error) {
      showBoundary(error); // the nearest ErrorBoundary renders its fallback
    }
  }

  return <button onClick={handleExport}>Export PDF</button>;
}

In React 19, errors thrown inside a startTransition callback (from useTransition) are also sent to the nearest boundary automatically.

Where to put boundaries#

Think about the user experience: what's the smallest piece of UI that can fail on its own?

JSX
function App() {
  return (
    <ErrorBoundary FallbackComponent={FullPageError}>
      {/* last-resort boundary for the whole app */}
      <Layout>
        <ErrorBoundary FallbackComponent={WidgetError}>
          <RecommendationsWidget />
        </ErrorBoundary>
        <ErrorBoundary FallbackComponent={WidgetError}>
          <CommentsSection />
        </ErrorBoundary>
        <MainContent />
      </Layout>
    </ErrorBoundary>
  );
}
  • A top-level boundary avoids blank screens.
  • Per-feature boundaries keep independent widgets (comments, charts, sidebars) from taking each other down.
  • With React Router, each route's ErrorBoundary acts as a per-page boundary.

Don't wrap every tiny component — too many boundaries make failures confusing.

Global error reporting in React 19#

createRoot accepts callbacks that fire for every error React handles — ideal for sending them to a monitoring service such as Sentry:

JSX
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root"), {
  onCaughtError(error, errorInfo) {
    // caught by an error boundary
    reportError({ error, componentStack: errorInfo.componentStack, handled: true });
  },
  onUncaughtError(error, errorInfo) {
    // NOT caught: React will unmount the app
    reportError({ error, componentStack: errorInfo.componentStack, handled: false });
  },
}).render(<App />);

Development vs production#

In development, Vite shows an error overlay and React logs the error even when a boundary catches it — that's expected. To see what users will see, close the overlay or test a production build with npm run build && npm run preview.

Common mistakes#

  • Expecting a boundary to catch errors from onClick handlers or setTimeout.
  • Having no top-level boundary, so any bug becomes a blank page.
  • Showing technical stack traces to users — show a friendly message and log details.
  • A "Try again" button that re-renders with the same broken data. Reset the cause (onReset, resetKeys) too.
  • Assuming error is always an Error instance.

What's next#

Error boundaries handle failure; Suspense handles waiting. Next: Suspense, lazy loading and transitions.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What happens by default in React when a component throws an error during rendering and there's no error boundary?

  2. 2.Which error will an error boundary NOT catch on its own?

  3. 3.In react-error-boundary, what does resetKeys={[userId]} do?

Finished reading?

Mark this lesson complete to track your progress.