React · lesson 15 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Error 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
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.