React Error Boundaries with Functional Components

To use a React error boundary with functional components, you still need one class: React has no hook that catches rendering errors, so the boundary itself has to be a class component even in an app made entirely of function components. You write that class once and wrap your function components in it. The usual answer in practice is the react-error-boundary package, which gives you the class plus a useErrorBoundary hook with showBoundary and resetBoundary for the errors boundaries cannot catch on their own. This guide builds both, with screenshots of a caught error, a recovery, and an event handler error being handed to the boundary.

Everything here was built and rendered with React 19.3.0 and react-error-boundary 6.1.6 in a Vite project, and each screenshot is the real component in a browser. Reference: catching rendering errors on react.dev.

Why a functional component cannot be an error boundary

Two class lifecycle methods make a boundary: static getDerivedStateFromError(), which switches to the fallback, and componentDidCatch(), which receives the error and the component stack for logging. There are no hook equivalents, which is the one thing the class-versus-hooks question still cannot resolve. So you write the class once and use it everywhere:

import { Component, useState } from "react";

// React has no hook for this: an error boundary has to be a class
class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };                      // render the fallback on the next render
  }

  componentDidCatch(error, info) {
    console.log("logged:", error.message, "| in", info.componentStack.trim().split("\n")[0]);
  }

  render() {
    if (this.state.error) {
      return (
        <div style={{ fontFamily: "system-ui", padding: 12, background: "#fff4f4",
                      border: "1px solid #f3c2c2", borderRadius: 6, color: "#8a1f1f" }}>
          <strong>Something went wrong in this section.</strong>
          <div style={{ fontSize: 13, marginTop: 4 }}>{this.state.error.message}</div>
        </div>
      );
    }
    return this.props.children;
  }
}

function SalesChart({ data }) {
  return <p>Total: ${data.total.toFixed(2)}</p>;     // data is undefined -> throws
}

export default function App() {
  const [orders] = useState(null);

  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <h3 style={{ margin: "0 0 8px" }}>Dashboard</h3>
      <p>Orders today: 14</p>
      <ErrorBoundary>
        <SalesChart data={orders} />
      </ErrorBoundary>
      <p style={{ marginTop: 8 }}>The rest of the page keeps working.</p>
    </div>
  );
}
A React dashboard where one section shows a fallback error message while the surrounding content still renders
The chart threw; the heading, the count and the paragraph below it are untouched.

That containment is the point of boundaries. Without one, an error anywhere in the tree unmounts the whole app and leaves a blank page, which is what React 19 does by design when nothing catches it.

Giving people a way back: reset

A fallback that only apologises is a dead end. Put a button in it that clears the boundary’s state, and change whatever caused the failure at the same time, otherwise the child throws again immediately. The screenshot below was taken after a real click on Try again:

import { Component, useState } from "react";

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

  static getDerivedStateFromError(error) {
    return { error };
  }

  reset = () => {
    this.setState({ error: null });
    this.props.onReset?.();
  };

  render() {
    if (this.state.error) {
      return (
        <div style={{ fontFamily: "system-ui", padding: 12, background: "#fff4f4",
                      border: "1px solid #f3c2c2", borderRadius: 6 }}>
          <p style={{ margin: "0 0 8px", color: "#8a1f1f" }}>{this.state.error.message}</p>
          <button id="retry" onClick={this.reset}
            style={{ padding: "6px 12px", borderRadius: 6, border: "1px solid #0b6bcb",
                     background: "#0b6bcb", color: "#fff", cursor: "pointer" }}>
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

function Report({ broken }) {
  if (broken) throw new Error("Could not load the report");
  return <p style={{ color: "#0a7d32" }}>Report loaded: 42 rows</p>;
}

export default function App() {
  const [broken, setBroken] = useState(true);

  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <ErrorBoundary key={String(broken)} onReset={() => setBroken(false)}>
        <Report broken={broken} />
      </ErrorBoundary>
    </div>
  );
}

Output:

[error] %o

%s

%s
 Error: Could not load the report
    at g (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:242:2590)
    at Object.react_stack_bottom_frame (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:226:251)
    at Ya (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:119:803)
    at Ys (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:135:2458)
    at gc (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:144:10556)
    at w (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:33:108)
    at Du (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:159:47479)
    at wu (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:159:46235)
    at Cu (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:159:46079)
    at lu (http://127.0.0.1:52155/assets/index-BCG8GDWV.js:159:39287) The above error occurred in the <g> component. React will try to recreate this component tree from scratch using the error boundary you provided, h.
A React error boundary after clicking try again, showing the recovered report content instead of the error message
After the click: the boundary reset and the report rendered.

Note the key on the boundary. Changing it forces React to build a fresh boundary instance, which is the simplest way to clear the error state along with the data that caused it.

Using the react-error-boundary package

This is the library almost every codebase ends up with, because it saves rewriting the class and adds the pieces you would otherwise build yourself: FallbackComponent (or fallbackRender, or a plain fallback element), onError for logging, onReset to fix the cause, and resetKeys to reset automatically when a value changes:

npm install react-error-boundary
import { useState } from "react";
import { ErrorBoundary } from "react-error-boundary";

function Fallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert" style={{ fontFamily: "system-ui", padding: 12, background: "#fff8e6",
                               border: "1px solid #f0d08a", borderRadius: 6 }}>
      <p style={{ margin: "0 0 8px" }}>Could not show the invoice: {error.message}</p>
      <button id="retry" onClick={resetErrorBoundary}
        style={{ padding: "6px 12px", borderRadius: 6, border: "1px solid #0b6bcb",
                 background: "#fff", color: "#0b6bcb", cursor: "pointer" }}>
        Reload it
      </button>
    </div>
  );
}

function Invoice({ id }) {
  if (id === null) throw new Error("no invoice selected");
  return <p>Invoice {id} is ready.</p>;
}

export default function App() {
  const [id, setId] = useState(null);

  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <ErrorBoundary
        FallbackComponent={Fallback}
        onError={(error) => console.log("sent to logging:", error.message)}
        onReset={() => setId("INV-1001")}      // fix the cause before retrying
        resetKeys={[id]}                        // or reset automatically when id changes
      >
        <Invoice id={id} />
      </ErrorBoundary>
    </div>
  );
}

Output:

[error] %o

%s

%s
 Error: no invoice selected
    at y (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:242:2411)
    at Object.react_stack_bottom_frame (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:226:251)
    at Ya (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:119:803)
    at Ys (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:135:2459)
    at gc (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:144:10557)
    at w (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:33:108)
    at Du (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:159:47490)
    at wu (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:159:46243)
    at Cu (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:159:46086)
    at lu (http://127.0.0.1:62248/assets/index-BjS-xUvM.js:159:39290) The above error occurred in the <y> component. React will try to recreate this component tree from scratch using the error boundary you provided, p.
sent to logging: no invoice selected
A React error boundary from the react-error-boundary library showing a fallback with a reload button, after the reset has loaded the invoice
onReset set a real invoice id, so the retry had something to show.

Errors boundaries do not catch, and what to do

A boundary only catches errors thrown while React renders the tree below it. Four things fall outside that:

  • Event handlers: a click handler that throws is ordinary JavaScript, not a render.
  • Async code: setTimeout callbacks, promise rejections and fetch handlers run after the render finished.
  • Server-side rendering: boundaries are a client-side mechanism.
  • The boundary itself: an error thrown inside the fallback goes to the boundary above it, if there is one.

useErrorBoundary() closes the first two. Catch the error yourself and call showBoundary(error), and the nearest boundary renders its fallback exactly as if the error had come from a render. The first button below handles the failure quietly; the second hands it over:

import { useState } from "react";
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";

function SaveButton() {
  const { showBoundary } = useErrorBoundary();
  const [status, setStatus] = useState("ready");

  const saveQuietly = () => {
    try {
      throw new Error("PUT /orders failed with 500");
    } catch (error) {
      console.log("caught in the handler, boundary never sees it:", error.message);
      setStatus("failed silently");
    }
  };

  const saveAndShow = () => {
    try {
      throw new Error("PUT /orders failed with 500");
    } catch (error) {
      showBoundary(error);                 // hand it to the nearest boundary
    }
  };

  return (
    <div style={{ fontFamily: "system-ui" }}>
      <p id="status">status: {status}</p>
      <button id="quiet" onClick={saveQuietly} style={{ marginRight: 8, padding: "6px 12px" }}>
        Save (handled quietly)
      </button>
      <button id="show" onClick={saveAndShow} style={{ padding: "6px 12px" }}>
        Save (show the boundary)
      </button>
    </div>
  );
}

export default function App() {
  return (
    <div style={{ padding: 16 }}>
      <ErrorBoundary fallback={<p style={{ fontFamily: "system-ui", color: "#8a1f1f" }}>
        The save failed. Your changes are still here.</p>}>
        <SaveButton />
      </ErrorBoundary>
    </div>
  );
}

Console output from the first click:

caught in the handler, boundary never sees it: PUT /orders failed with 500
A React app showing the error boundary fallback after a save button handed its error to the boundary with showBoundary
After the second click: the handler’s error reached the boundary.

Which one you want depends on the failure. A validation message belongs next to the field; a save that lost the user’s work belongs in a boundary. For the render-time mistakes that get you here in the first place, see updating a component while rendering another and a component that does not render at all.

Thrown whereCaught by a boundary?What to do
Render of a childYesNothing, the fallback shows
A lifecycle method or effectYesNothing
An event handlerNotry/catch plus showBoundary()
setTimeout or a promiseNoCatch it, then showBoundary()
Inside the fallbackNoA boundary further up
Server-side renderingNoHandle it in the server framework

More React guides worth reading:

Frequently asked questions

Can I create an error boundary with a functional component?

No. getDerivedStateFromError and componentDidCatch exist only on classes. Write the class once, or use the react-error-boundary package.

What is useErrorBoundary used for?

It gives a function component showBoundary(error) to hand an error to the nearest boundary, and resetBoundary() to clear it.

Why does my error boundary not catch the error?

It probably came from an event handler, a timer or a promise. Catch it yourself and call showBoundary().

How do I reset a React error boundary?

Call resetErrorBoundary() from the fallback, pass resetKeys, or change the boundary’s key so React remounts it.

Where should I put error boundaries?

Around each independent section, so one failing widget does not blank the page, plus one at the top as a last resort.

Do error boundaries work in React 19?

Yes, unchanged. React 19 also adds onUncaughtError and onCaughtError options when you create the root, for logging.

Does react-error-boundary work with TypeScript?

Yes, it ships its own types, and the fallback receives a typed error and resetErrorBoundary.

Leave a Comment