Skip to main content
React · lesson 15 of 22
React intermediate Lesson 15 of 22

Error Boundaries

Write a class-based error boundary, watch it catch a real render TypeError and show its fallback UI, then see the one error it never reaches: a throw inside an onClick handler, which React routes around componentDidCatch entirely.

By SkillByExample · Updated

react error-boundaries vitest testing

An error boundary is a class component that catches a JavaScript error thrown anywhere in the component tree below it — but only an error thrown during rendering, a lifecycle method, or a constructor. React’s own docs are explicit that an error thrown from an event handler is not one of those cases, and this lesson proves it for real: the same boundary that catches a render error does nothing at all for one thrown from onClick.

Project Setup

The same kind of Vitest project as State with useState:

{
  "name": "react-app",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "vitest": "5.0.5",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.8",
    "@testing-library/jest-dom": "7.0.1",
    "@vitejs/plugin-react": "6.1.2",
    "jsdom": "30.1.3"
  }
}
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.js"],
  },
});
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";

afterEach(() => {
  cleanup();
});

Every command below runs inside Docker’s node:24.18.1 image, from /root/react-app, with NO_COLOR=1, and grep strips Vitest’s own Start at/Duration lines, since their numbers change on every run:

$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund

added 98 packages in 27s

A Class Component That Catches a Render Error

Boundary is the error boundary itself: getDerivedStateFromError flips its state to render fallback instead of children, and componentDidCatch is where a real app would log the error. Profile throws while rendering, because user has no name:

// src/error-boundaries/Boundary.tsx
import { Component, type ReactNode } from "react";

type BoundaryProps = { children: ReactNode; fallback: ReactNode };
type BoundaryState = { hasError: boolean };

export class Boundary extends Component<BoundaryProps, BoundaryState> {
  state: BoundaryState = { hasError: false };

  static getDerivedStateFromError(): BoundaryState {
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    console.error(`componentDidCatch saw: ${error.message}`);
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}
// src/error-boundaries/Profile.tsx
type ProfileProps = { user: { name?: string } };

export function Profile({ user }: ProfileProps) {
  return <p>Name: {user.name!.toUpperCase()}</p>;
}
// src/error-boundaries/App.tsx
import { Boundary } from "./Boundary.js";
import { Profile } from "./Profile.js";

export function App() {
  return (
    <Boundary fallback={<p role="alert">Something went wrong.</p>}>
      <Profile user={{}} />
    </Boundary>
  );
}

App passes user={{}} — an object with no name at all — so user.name!.toUpperCase() throws a real TypeError the moment Profile renders:

// src/error-boundaries/Boundary.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { App } from "./App.js";

describe("a class error boundary", () => {
  it("renders its fallback instead of letting the render error crash the tree", () => {
    render(<App />);

    console.error(`fallback shows: ${screen.getByRole("alert").textContent}`);
    expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong.");
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/error-boundaries/Boundary.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stderr | src/error-boundaries/Boundary.test.tsx > a class error boundary > renders its fallback instead of letting the render error crash the tree
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
    at Profile (/root/react-app/src/error-boundaries/Profile.tsx:5:31)
    at Object.react_stack_bottom_frame (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:28571:20)
    at renderWithHooks (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:8030:22)
    at updateFunctionComponent (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:10569:19)
    at beginWork (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:12205:18)
    at runWithFiberInDEV (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:1048:13)
    at performUnitOfWork (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:19176:22)
    at workLoopSync (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:19004:41)
    at renderRootSync (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:18985:11)
    at performWorkOnRoot (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:18094:35) {
  [stack]: [Getter/Setter],
  [message]: "Cannot read properties of undefined (reading 'toUpperCase')"
}

The above error occurred in the <Profile> component.

React will try to recreate this component tree from scratch using the error boundary you provided, Boundary.

componentDidCatch saw: Cannot read properties of undefined (reading 'toUpperCase')
fallback shows: Something went wrong.

 ✓ src/error-boundaries/Boundary.test.tsx > a class error boundary > renders its fallback instead of letting the render error crash the tree 137ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

The test passes, but it’s not quiet about it: React logs the error itself before componentDidCatch ever runs — the TypeError message, The above error occurred in the <Profile> component., and React will try to recreate this component tree from scratch using the error boundary you provided, Boundary. all print to stderr, right above componentDidCatch saw: ... and the fallback text the assertion checks. React does this logging on its own, independent of componentDidCatch — it happens even for a boundary that never calls console.log at all.

Capturing What the Boundary Caught, Without the Default Logging

@testing-library/react’s render forwards an onCaughtError option straight to the underlying createRoot call. React 19 calls it with the same error componentDidCatch receives — and, passing it, React skips its own default console.error reporting for that root entirely:

// src/error-boundaries/OnCaughtError.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { App } from "./App.js";

describe("render's onCaughtError option", () => {
  it("reports exactly what the boundary caught, with no default console.error block", () => {
    const caught: string[] = [];

    render(<App />, {
      onCaughtError(error) {
        caught.push((error as Error).message);
      },
    });

    console.error(`onCaughtError saw: ${caught.join(", ")}`);
    expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong.");
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/error-boundaries/OnCaughtError.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stderr | src/error-boundaries/OnCaughtError.test.tsx > render's onCaughtError option > reports exactly what the boundary caught, with no default console.error block
componentDidCatch saw: Cannot read properties of undefined (reading 'toUpperCase')
onCaughtError saw: Cannot read properties of undefined (reading 'toUpperCase')

 ✓ src/error-boundaries/OnCaughtError.test.tsx > render's onCaughtError option > reports exactly what the boundary caught, with no default console.error block 183ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Compare this to the previous block: componentDidCatch saw: ... still prints, because Boundary itself is unchanged, but React’s own stderr block — the TypeError stack, the <Profile> sentence, the “recreate this component tree” sentence — is gone. onCaughtError took over reporting for this render instead of adding to it, which is exactly what makes it useful in a test: an assertion can check caught without the test’s own output being buried in React’s default error block every time.

What an Error Boundary Can’t Catch: Event Handlers

Danger throws from its own onClick, not from rendering — nothing about rendering Danger itself fails:

// src/error-boundaries/Danger.tsx
export function Danger() {
  return (
    <button
      onClick={() => {
        throw new Error("boom from onClick");
      }}
    >
      Break
    </button>
  );
}
// src/error-boundaries/ClickApp.tsx
import { Boundary } from "./Boundary.js";
import { Danger } from "./Danger.js";

export function ClickApp() {
  return (
    <Boundary fallback={<p role="alert">Something went wrong.</p>}>
      <Danger />
    </Boundary>
  );
}

The same Boundary wraps Danger here. Clicking the button runs the onClick throw after React has already finished rendering and committing the button — there’s no render for Boundary to intercept:

// src/error-boundaries/ClickApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ClickApp } from "./ClickApp.js";

describe("an error boundary around a click handler", () => {
  it("does not catch an error thrown from inside onClick", async () => {
    const user = userEvent.setup();
    render(<ClickApp />);

    await user.click(screen.getByRole("button", { name: "Break" }));

    console.log("still rendered:", screen.getByRole("button", { name: "Break" }).textContent);
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
  });
});

This command’s exit code is part of the output on purpose — Vitest treats the onClick throw as an unhandled error, separate from the test result, and exits non-zero even though the one test above passes:

$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 sh -c 'npx vitest run src/error-boundaries/ClickApp.test.tsx --reporter=verbose; echo vitest exit code $?' 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/error-boundaries/ClickApp.test.tsx > an error boundary around a click handler > does not catch an error thrown from inside onClick
still rendered: Break

 ✓ src/error-boundaries/ClickApp.test.tsx > an error boundary around a click handler > does not catch an error thrown from inside onClick 200ms
⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯

Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.

⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯
Error: boom from onClick
 ❯ onClick src/error-boundaries/Danger.tsx:6:15
      4|     <button
      5|       onClick={() => {
      6|         throw new Error("boom from onClick");
       |               ^
      7|       }}
      8|     >
 ❯ executeDispatch node_modules/react-dom/cjs/react-dom-client.development.js:20804:9
 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1048:13
 ❯ processDispatchQueue node_modules/react-dom/cjs/react-dom-client.development.js:20854:19
 ❯ node_modules/react-dom/cjs/react-dom-client.development.js:21423:9
 ❯ batchedUpdates$1 node_modules/react-dom/cjs/react-dom-client.development.js:3437:40
 ❯ dispatchEventForPluginEventSystem node_modules/react-dom/cjs/react-dom-client.development.js:21008:7
 ❯ dispatchEvent node_modules/react-dom/cjs/react-dom-client.development.js:26142:11
 ❯ dispatchDiscreteEvent node_modules/react-dom/cjs/react-dom-client.development.js:26110:11
 ❯ HTMLDivElement.callTheUserObjectsOperation node_modules/jsdom/lib/generated/idl/EventListener.js:26:30

This error originated in "src/error-boundaries/ClickApp.test.tsx" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The last test to run before this error was "does not catch an error thrown from inside onClick". This means either:
- the error was thrown while Vitest was running this test, or
- the error was thrown after the test completed, and this was the most recent test at that point.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


 Test Files  1 passed (1)
      Tests  1 passed (1)
     Errors  1 error

vitest exit code 1

still rendered: Break and queryByRole("alert") finding nothing confirm the DOM never changed — Boundary is still showing Danger, not its fallback. Test Files 1 passed (1) / Tests 1 passed (1) show the one assertion above was true. And then Vitest reports Vitest caught 1 unhandled error during the test run. and the whole run exits with vitest exit code 1 anyway: the onClick throw escaped every try/catch React or Vitest had in place for it, surfaced later as an unhandled error, and Vitest treats that as a failing run regardless of what the tests themselves asserted. This isn’t a bug in the test — it’s React’s documented boundary: render, lifecycle, and constructor errors only. The fix, per React’s own docs, is a plain try/catch inside onClick itself, which Practice 2 below adds.

Practice

1. Make Boundary’s child throw from componentDidMount instead of during render, and confirm the same boundary still catches it — error boundaries aren’t limited to render-time errors.

Solution
// src/error-boundaries/practice/MountThrows.tsx
import { Component } from "react";

export class MountThrows extends Component {
  componentDidMount() {
    throw new Error("boom from componentDidMount");
  }

  render() {
    return <p>About to mount...</p>;
  }
}
// src/error-boundaries/practice/MountThrowsApp.tsx
import { Boundary } from "../Boundary.js";
import { MountThrows } from "./MountThrows.js";

export function MountThrowsApp() {
  return (
    <Boundary fallback={<p role="alert">Something went wrong.</p>}>
      <MountThrows />
    </Boundary>
  );
}
// src/error-boundaries/practice/MountThrows.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MountThrowsApp } from "./MountThrowsApp.js";

describe("an error boundary around a componentDidMount throw", () => {
  it("catches a lifecycle error the same way it catches a render error", () => {
    render(<MountThrowsApp />, {
      onCaughtError(error) {
        console.error(`onCaughtError saw: ${(error as Error).message}`);
      },
    });

    expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong.");
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/error-boundaries/practice/MountThrows.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stderr | src/error-boundaries/practice/MountThrows.test.tsx > an error boundary around a componentDidMount throw > catches a lifecycle error the same way it catches a render error
onCaughtError saw: boom from componentDidMount
componentDidCatch saw: boom from componentDidMount

 ✓ src/error-boundaries/practice/MountThrows.test.tsx > an error boundary around a componentDidMount throw > catches a lifecycle error the same way it catches a render error 124ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

onCaughtError saw: boom from componentDidMount — the same Boundary class, unchanged, catches this too. componentDidMount runs during React’s commit phase, not the render phase, but it’s still a lifecycle method of a component below the boundary, which is exactly the scope React’s docs describe: render, lifecycle methods, and constructors, anywhere in the tree below the boundary.

2. Fix Danger itself: wrap the onClick body in a plain try/catch and track the error in local state, the way React’s own docs recommend for event-handler errors.

Solution
// src/error-boundaries/practice/CaughtDanger.tsx
import { useState } from "react";

export function CaughtDanger() {
  const [error, setError] = useState<string | null>(null);

  if (error) {
    return <p role="alert">Caught locally: {error}</p>;
  }

  return (
    <button
      onClick={() => {
        try {
          throw new Error("boom from onClick");
        } catch (caught) {
          setError((caught as Error).message);
        }
      }}
    >
      Break
    </button>
  );
}
// src/error-boundaries/practice/CaughtDanger.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CaughtDanger } from "./CaughtDanger.js";

describe("a plain try/catch inside the handler itself", () => {
  it("recovers without needing an error boundary at all", async () => {
    const user = userEvent.setup();
    render(<CaughtDanger />);

    await user.click(screen.getByRole("button", { name: "Break" }));

    console.log(screen.getByRole("alert").textContent);
    expect(screen.getByRole("alert")).toHaveTextContent("Caught locally: boom from onClick");
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 sh -c 'npx vitest run src/error-boundaries/practice/CaughtDanger.test.tsx --reporter=verbose; echo vitest exit code $?' 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/error-boundaries/practice/CaughtDanger.test.tsx > a plain try/catch inside the handler itself > recovers without needing an error boundary at all
Caught locally: boom from onClick

 ✓ src/error-boundaries/practice/CaughtDanger.test.tsx > a plain try/catch inside the handler itself > recovers without needing an error boundary at all 198ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

vitest exit code 0

vitest exit code 0 this time — no boundary involved, and nothing escapes as an unhandled error, because the try/catch is sitting right where the throw happens. setError then triggers a normal re-render showing Caught locally: boom from onClick, which is CaughtDanger’s own state, not a boundary’s fallback.

3. Wrap two Profiles, one broken and one fine, each in its own Boundary, and confirm the broken one’s fallback shows while the fine one keeps rendering normally.

Solution
// src/error-boundaries/practice/TwoProfiles.tsx
import { Boundary } from "../Boundary.js";
import { Profile } from "../Profile.js";

export function TwoProfiles() {
  return (
    <div>
      <Boundary fallback={<p role="alert">Something went wrong.</p>}>
        <Profile user={{}} />
      </Boundary>
      <Boundary fallback={<p role="alert">Something went wrong.</p>}>
        <Profile user={{ name: "Ada" }} />
      </Boundary>
    </div>
  );
}
// src/error-boundaries/practice/TwoProfiles.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { TwoProfiles } from "./TwoProfiles.js";

describe("two independent error boundaries", () => {
  it("isolates the broken subtree without affecting the working one", () => {
    render(<TwoProfiles />, { onCaughtError() {} });

    console.error(`alert shows: ${screen.getByRole("alert").textContent}`);
    console.error(`sibling shows: ${screen.getByText("Name: ADA").textContent}`);
    expect(screen.getAllByRole("alert")).toHaveLength(1);
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/error-boundaries/practice/TwoProfiles.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stderr | src/error-boundaries/practice/TwoProfiles.test.tsx > two independent error boundaries > isolates the broken subtree without affecting the working one
componentDidCatch saw: Cannot read properties of undefined (reading 'toUpperCase')
alert shows: Something went wrong.
sibling shows: Name: ADA

 ✓ src/error-boundaries/practice/TwoProfiles.test.tsx > two independent error boundaries > isolates the broken subtree without affecting the working one 159ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

One alert and one Name: ADA, both present at once: the first Boundary caught its Profile’s render error and switched to its fallback, while the second Boundary never caught anything at all, because nothing below it threw. Each Boundary only watches its own subtree — a sibling boundary catching an error has no effect on the tree outside it.

4. Render App with no Boundary at all — just <Profile user={{}} /> — and see what a render error does with nothing in the tree above it to catch it.

Solution
// src/error-boundaries/practice/NoBoundary.test.tsx
import { describe, it } from "vitest";
import { render } from "@testing-library/react";
import { Profile } from "../Profile.js";

describe("a render error with no boundary above it", () => {
  it("throws out of render instead of being caught anywhere", () => {
    render(<Profile user={{}} />);
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-error-boundaries-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 sh -c 'npx vitest run src/error-boundaries/practice/NoBoundary.test.tsx --reporter=verbose; echo vitest exit code $?' 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

 × src/error-boundaries/practice/NoBoundary.test.tsx > a render error with no boundary above it > throws out of render instead of being caught anywhere 30ms
   → Cannot read properties of undefined (reading 'toUpperCase')

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/error-boundaries/practice/NoBoundary.test.tsx > a render error with no boundary above it > throws out of render instead of being caught anywhere
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
 ❯ Profile src/error-boundaries/Profile.tsx:5:31
      3|
      4| export function Profile({ user }: ProfileProps) {
      5|   return <p>Name: {user.name!.toUpperCase()}</p>;
       |                               ^
      6| }
      7|
 ❯ Object.react_stack_bottom_frame node_modules/react-dom/cjs/react-dom-client.development.js:28571:20
 ❯ renderWithHooks node_modules/react-dom/cjs/react-dom-client.development.js:8030:22
 ❯ updateFunctionComponent node_modules/react-dom/cjs/react-dom-client.development.js:10569:19
 ❯ beginWork node_modules/react-dom/cjs/react-dom-client.development.js:12205:18
 ❯ runWithFiberInDEV node_modules/react-dom/cjs/react-dom-client.development.js:1048:13
 ❯ performUnitOfWork node_modules/react-dom/cjs/react-dom-client.development.js:19176:22
 ❯ workLoopSync node_modules/react-dom/cjs/react-dom-client.development.js:19004:41
 ❯ renderRootSync node_modules/react-dom/cjs/react-dom-client.development.js:18985:11
 ❯ performWorkOnRoot node_modules/react-dom/cjs/react-dom-client.development.js:18094:35

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)

vitest exit code 1

With no Boundary anywhere in the tree, the TypeError has nowhere to go: the test itself fails with that error, rather than a boundary quietly switching to a fallback. This is the case a boundary exists to prevent in a real app — without one, a single component’s render error would unmount the whole tree React was managing, not just the broken piece.

Frequently Asked Questions

What kinds of errors does a React error boundary actually catch?
An error boundary only catches errors thrown while React is rendering, in a lifecycle method, or in a constructor, anywhere in the component tree below it. It does not catch errors in event handlers, errors in asynchronous code such as a setTimeout callback or a rejected promise, errors thrown during server-side rendering, or an error thrown by the boundary component's own code rather than its children's.
Why doesn't an error boundary catch an error thrown inside an onClick handler?
An event handler runs outside of React's render phase — by the time it fires, React has already finished rendering and committing that part of the tree, so there is nothing left for a boundary to intercept. React's own documentation recommends a plain JavaScript try/catch inside the handler itself for that case, since the component that owns the handler is already in the best position to recover.
What is the difference between getDerivedStateFromError and componentDidCatch?
getDerivedStateFromError is a static method called during React's render phase; it returns the state update that switches the boundary to its fallback UI, and must not have side effects such as logging. componentDidCatch runs during the commit phase, after the fallback is already showing, and is where side effects like logging the error belong. Only a class component can define either method — there is no hook equivalent of an error boundary itself.
Does render's onCaughtError option replace console.error, or add to it?
It replaces React's own console.error reporting for that specific render root. Passing onCaughtError to @testing-library/react's render (which forwards it to the underlying createRoot call) hands you the same error object and component stack React would otherwise have logged, so a test can assert on exactly what a boundary caught without also printing React's default error block to the terminal.
Is there a hook-based replacement for class-based error boundaries?
Not in core React. As of React 19, catching a render error still requires a class component implementing getDerivedStateFromError and/or componentDidCatch — a boundary is a component, not a hook, and React itself ships no hook equivalent. The react-error-boundary package does export a useErrorBoundary hook, but it's for routing an error into an existing boundary from elsewhere (an onClick handler, for instance), not for defining the boundary itself — see the 'Retrying After an Error with react-error-boundary' lesson.