React · lesson 18 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
5Retrying After an Error with react-error-boundary
Add a Retry button to a React error boundary with the react-error-boundary library's FallbackComponent and onReset, then watch a rejected promise read with use() land in the same boundary as a real thrown error.
By SkillByExample · Updated
Error Boundaries showed that a hand-written class boundary catches a render error but has no way back to the working UI except reloading the page — hasError only ever flips to true. The react-error-boundary package adds exactly that missing piece: a resetErrorBoundary function the fallback can call, plus onReset and resetKeys to decide what “retry” actually clears. This lesson wires up a Retry button for real, confirms a promise rejected inside use() lands in the same boundary a thrown render error would, and shows one real wrinkle in React’s own error message along the way.
Project Setup
The same kind of Vitest project as Error Boundaries, plus react-error-boundary itself:
{
"name": "react-app",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6"
},
"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-recovering-from-errors-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund
added 99 packages in 28s
FallbackComponent and onReset: Retrying Without Reloading
Fallback reads two props ErrorBoundary hands it: error, the thing that was thrown, and resetErrorBoundary, a function that clears the boundary’s own error state and re-renders its children:
// src/recovering-from-errors/Fallback.tsx
export function Fallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
return (
<div role="alert">
<p>{String(error)}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
FlakySearch throws for as long as a module-level flag stays true — standing in for a search index that’s down until something fixes it:
// src/recovering-from-errors/FlakySearch.tsx
export let searchIsDown = true;
export function markSearchFixed() {
searchIsDown = false;
}
export function FlakySearch() {
if (searchIsDown) {
throw new Error("search index unavailable");
}
return <p>Loaded</p>;
}
RetrySearchApp wraps it in ErrorBoundary with FallbackComponent={Fallback} and an onReset that fixes the actual problem — flips the flag — before React re-renders FlakySearch:
// src/recovering-from-errors/RetrySearchApp.tsx
import { ErrorBoundary } from "react-error-boundary";
import { FlakySearch, markSearchFixed } from "./FlakySearch.js";
import { Fallback } from "./Fallback.js";
export function RetrySearchApp() {
return (
<ErrorBoundary
FallbackComponent={Fallback}
onReset={() => {
console.error("onReset");
markSearchFixed();
}}
>
<FlakySearch />
</ErrorBoundary>
);
}
FlakySearch throws the instant it renders, so the fallback is already showing by the time the test gets a reference to the DOM. Clicking “Retry” calls resetErrorBoundary, which runs onReset, then re-renders FlakySearch — now past the flag onReset just flipped:
// src/recovering-from-errors/RetrySearchApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RetrySearchApp } from "./RetrySearchApp.js";
describe("retrying with FallbackComponent and onReset", () => {
it("clicking Retry calls onReset, then re-renders to the loaded state", async () => {
const user = userEvent.setup();
render(<RetrySearchApp />);
console.error("fallback shows:", screen.getByRole("alert").querySelector("p")!.textContent);
await user.click(screen.getByRole("button", { name: "Retry" }));
console.error("after: loaded");
expect(screen.getByText("Loaded")).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-recovering-from-errors-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/recovering-from-errors/RetrySearchApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/recovering-from-errors/RetrySearchApp.test.tsx > retrying with FallbackComponent and onReset > clicking Retry calls onReset, then re-renders to the loaded state
Error: search index unavailable
at FlakySearch (/root/react-app/src/recovering-from-errors/FlakySearch.tsx:10:11)
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]: 'search index unavailable'
}
The above error occurred in the <FlakySearch> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
fallback shows: Error: search index unavailable
stderr | src/recovering-from-errors/RetrySearchApp.test.tsx > retrying with FallbackComponent and onReset > clicking Retry calls onReset, then re-renders to the loaded state
onReset
stderr | src/recovering-from-errors/RetrySearchApp.test.tsx > retrying with FallbackComponent and onReset > clicking Retry calls onReset, then re-renders to the loaded state
after: loaded
✓ src/recovering-from-errors/RetrySearchApp.test.tsx > retrying with FallbackComponent and onReset > clicking Retry calls onReset, then re-renders to the loaded state 230ms
Test Files 1 passed (1)
Tests 1 passed (1)
fallback shows: Error: search index unavailable confirms the fallback rendered String(error), React’s own Error: <message> form. Right above it is a sentence worth reading twice: React will try to recreate this component tree from scratch using the error boundary you provided, y. — not ErrorBoundary, a bare y. React logs whichever name the boundary component’s own .name reports, and react-error-boundary ships its ErrorBoundary class inside an already-minified bundle, even in this non-production build — so that name was thrown away at the library’s own build step, long before this test ever ran. It isn’t a bug to chase down in this project’s own code; it’s a property of the dependency. The lines after it are the ones that matter for this lesson: onReset prints from the callback, then after: loaded and the passing assertion confirm the retry actually worked — FlakySearch rendered <p>Loaded</p> instead of throwing a second time, because onReset fixed the real condition the component was checking, not just the boundary’s own display state.
A Rejected Promise Read with use() Lands in the Boundary, Too
lookupUser caches one promise per id, the same pattern Reading Promises with use used — except this one rejects, simulating a user that a real API would answer with a 404 for:
// src/recovering-from-errors/userLookup.tsx
const cache = new Map();
export function lookupUser(id) {
if (!cache.has(id)) {
cache.set(id, Promise.reject(new Error(`404 user not found`)));
}
return cache.get(id);
}
UserProfile reads that promise with use(), no try/catch of its own:
// src/recovering-from-errors/UserProfile.tsx
import { use } from "react";
import { lookupUser } from "./userLookup.js";
export function UserProfile({ id }) {
const user = use(lookupUser(id));
return <p>Hello, {user.name}</p>;
}
UserLookupApp puts the same ErrorBoundary/Fallback pair around a Suspense boundary wrapping UserProfile — Suspense is still there in case some other promise this boundary guards is merely pending, but it has nothing to do with what happens when one actually rejects:
// src/recovering-from-errors/UserLookupApp.tsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { UserProfile } from "./UserProfile.js";
import { Fallback } from "./Fallback.js";
export function UserLookupApp() {
return (
<ErrorBoundary FallbackComponent={Fallback}>
<Suspense fallback={<p>Loading...</p>}>
<UserProfile id={1} />
</Suspense>
</ErrorBoundary>
);
}
This test lives in its own file, with no user-event import at all, so the only thing exercising React’s asynchronous behavior is the await act(async () => { render(...) }) wrapper Reading Promises with use established for any use()-on-a-promise render:
// src/recovering-from-errors/UserLookupApp.test.tsx
import { describe, it, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { UserLookupApp } from "./UserLookupApp.js";
describe("a rejected use() promise inside an ErrorBoundary", () => {
it("lands in the boundary as a real thrown error, same as a render error would", async () => {
await act(async () => {
render(<UserLookupApp />);
});
console.error("alert shows:", screen.getByRole("alert").querySelector("p")!.textContent);
expect(screen.getByRole("alert")).toHaveTextContent("404 user not found");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-recovering-from-errors-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/recovering-from-errors/UserLookupApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/recovering-from-errors/UserLookupApp.test.tsx > a rejected use() promise inside an ErrorBoundary > lands in the boundary as a real thrown error, same as a render error would
Error: 404 user not found
at lookupUser (/root/react-app/src/recovering-from-errors/userLookup.tsx:6:34)
at UserProfile (/root/react-app/src/recovering-from-errors/UserProfile.tsx:6:20)
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) {
[stack]: [Getter/Setter],
[message]: '404 user not found'
}
The above error occurred in the <UserProfile> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
stderr | src/recovering-from-errors/UserLookupApp.test.tsx > a rejected use() promise inside an ErrorBoundary > lands in the boundary as a real thrown error, same as a render error would
alert shows: Error: 404 user not found
✓ src/recovering-from-errors/UserLookupApp.test.tsx > a rejected use() promise inside an ErrorBoundary > lands in the boundary as a real thrown error, same as a render error would 288ms
Test Files 1 passed (1)
Tests 1 passed (1)
alert shows: Error: 404 user not found — no try/catch anywhere in UserProfile or lookupUser, and nothing special done to route the rejection anywhere. use() propagates a rejected promise the exact same way it would propagate a value: by throwing, synchronously, into whichever render is currently in progress. From there it’s indistinguishable from FlakySearch’s plain throw above — same ErrorBoundary, same Fallback, same minified y in React’s console message. Suspense never enters the picture for this outcome at all; it only ever intercepts the pending state, and this promise never stayed pending.
Practice
1. Click “Retry” without calling markSearchFixed first, and confirm the exact same error comes back — a retry only re-runs the children, it doesn’t guarantee they’ll succeed.
Solution
// src/recovering-from-errors/practice/StillFlaky.tsx
export function StillFlaky() {
throw new Error("search index unavailable");
}
// src/recovering-from-errors/practice/StillFlakyApp.tsx
import { ErrorBoundary } from "react-error-boundary";
import { StillFlaky } from "./StillFlaky.js";
import { Fallback } from "../Fallback.js";
export function StillFlakyApp() {
return (
<ErrorBoundary FallbackComponent={Fallback} onReset={() => console.error("onReset")}>
<StillFlaky />
</ErrorBoundary>
);
}
// src/recovering-from-errors/practice/StillFlakyApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { StillFlakyApp } from "./StillFlakyApp.js";
describe("retrying without fixing the underlying problem", () => {
it("shows the same error again", async () => {
const user = userEvent.setup();
render(<StillFlakyApp />);
console.error("before retry:", screen.getByRole("alert").querySelector("p")!.textContent);
await user.click(screen.getByRole("button", { name: "Retry" }));
console.error("after retry:", screen.getByRole("alert").querySelector("p")!.textContent);
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-recovering-from-errors-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/recovering-from-errors/practice/StillFlakyApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/recovering-from-errors/practice/StillFlakyApp.test.tsx > retrying without fixing the underlying problem > shows the same error again
Error: search index unavailable
at StillFlaky (/root/react-app/src/recovering-from-errors/practice/StillFlaky.tsx:3:9)
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]: 'search index unavailable'
}
The above error occurred in the <StillFlaky> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
before retry: Error: search index unavailable
stderr | src/recovering-from-errors/practice/StillFlakyApp.test.tsx > retrying without fixing the underlying problem > shows the same error again
onReset
Error: search index unavailable
at StillFlaky (/root/react-app/src/recovering-from-errors/practice/StillFlaky.tsx:3:9)
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]: 'search index unavailable'
}
The above error occurred in the <StillFlaky> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
stderr | src/recovering-from-errors/practice/StillFlakyApp.test.tsx > retrying without fixing the underlying problem > shows the same error again
after retry: Error: search index unavailable
✓ src/recovering-from-errors/practice/StillFlakyApp.test.tsx > retrying without fixing the underlying problem > shows the same error again 450ms
Test Files 1 passed (1)
Tests 1 passed (1)
before retry and after retry print the identical Error: search index unavailable, and onReset still fires in between — resetErrorBoundary did its job, clearing the boundary’s state and giving StillFlaky a fresh render. StillFlaky just has no way to succeed, since nothing in this version ever flips a flag. Retry is a mechanism for re-attempting, not a guarantee; whatever onReset is wired to do is what actually decides whether the next attempt is any different from the last one.
2. Add an onError callback alongside onReset and confirm it fires exactly once, when the error is first caught — not again when Retry is clicked.
Solution
// src/recovering-from-errors/practice/OnErrorApp.tsx
import { ErrorBoundary } from "react-error-boundary";
import { FlakySearch, markSearchFixed } from "../FlakySearch.js";
import { Fallback } from "../Fallback.js";
export function OnErrorApp({ onError }: { onError: (error: Error) => void }) {
return (
<ErrorBoundary
FallbackComponent={Fallback}
onError={onError}
onReset={() => {
console.error("onReset");
markSearchFixed();
}}
>
<FlakySearch />
</ErrorBoundary>
);
}
// src/recovering-from-errors/practice/OnErrorApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OnErrorApp } from "./OnErrorApp.js";
describe("onError fires once per catch, onReset fires once per retry", () => {
it("errorCount stays at 1 after a successful retry", async () => {
const user = userEvent.setup();
let errorCount = 0;
render(<OnErrorApp onError={() => { errorCount++; console.error("onError, count:", errorCount); }} />);
await user.click(screen.getByRole("button", { name: "Retry" }));
console.error("errorCount after one retry:", errorCount);
expect(screen.getByText("Loaded")).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-recovering-from-errors-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/recovering-from-errors/practice/OnErrorApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/recovering-from-errors/practice/OnErrorApp.test.tsx > onError fires once per catch, onReset fires once per retry > errorCount stays at 1 after a successful retry
Error: search index unavailable
at FlakySearch (/root/react-app/src/recovering-from-errors/FlakySearch.tsx:10:11)
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]: 'search index unavailable'
}
The above error occurred in the <FlakySearch> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
onError, count: 1
stderr | src/recovering-from-errors/practice/OnErrorApp.test.tsx > onError fires once per catch, onReset fires once per retry > errorCount stays at 1 after a successful retry
onReset
stderr | src/recovering-from-errors/practice/OnErrorApp.test.tsx > onError fires once per catch, onReset fires once per retry > errorCount stays at 1 after a successful retry
errorCount after one retry: 1
✓ src/recovering-from-errors/practice/OnErrorApp.test.tsx > onError fires once per catch, onReset fires once per retry > errorCount stays at 1 after a successful retry 434ms
Test Files 1 passed (1)
Tests 1 passed (1)
onError, count: 1 prints once, right when FlakySearch’s throw is first caught — before onReset ever runs. errorCount after one retry: 1 confirms it never incremented again, even though the click that followed re-ran the whole children tree. onError is for reporting the catch itself (logging it to a service, say); onReset is for deciding what to clean up before trying again. A successful retry triggers one, not the other.
3. Add a resetKeys prop to ErrorBoundary so it resets automatically whenever a value changes — reusing the same Fallback with its Retry button, but never clicking it.
Solution
// src/recovering-from-errors/practice/ResetKeysApp.tsx
import { useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { Fallback } from "../Fallback.js";
function FlakyUntilAttempt2({ attempt }: { attempt: number }) {
if (attempt < 2) {
throw new Error(`attempt ${attempt} failed`);
}
return <p>Loaded</p>;
}
export function ResetKeysApp() {
const [attempt, setAttempt] = useState(0);
return (
<div>
<button onClick={() => setAttempt((a) => a + 1)}>Bump attempt</button>
<ErrorBoundary FallbackComponent={Fallback} resetKeys={[attempt]}>
<FlakyUntilAttempt2 attempt={attempt} />
</ErrorBoundary>
</div>
);
}
// src/recovering-from-errors/practice/ResetKeysApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ResetKeysApp } from "./ResetKeysApp.js";
describe("resetKeys resets the boundary automatically", () => {
it("resets as soon as the key prop changes, even though Fallback's Retry button is never clicked", async () => {
const user = userEvent.setup();
render(<ResetKeysApp />);
console.error("after attempt 0:", screen.getByRole("alert").querySelector("p")!.textContent);
await user.click(screen.getByRole("button", { name: "Bump attempt" }));
console.error("after attempt 1:", screen.getByRole("alert").querySelector("p")!.textContent);
await user.click(screen.getByRole("button", { name: "Bump attempt" }));
expect(screen.getByText("Loaded")).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-recovering-from-errors-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/recovering-from-errors/practice/ResetKeysApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/recovering-from-errors/practice/ResetKeysApp.test.tsx > resetKeys resets the boundary automatically > resets as soon as the key prop changes, even though Fallback's Retry button is never clicked
Error: attempt 0 failed
at FlakyUntilAttempt2 (/root/react-app/src/recovering-from-errors/practice/ResetKeysApp.tsx:8:11)
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]: 'attempt 0 failed'
}
The above error occurred in the <FlakyUntilAttempt2> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
after attempt 0: Error: attempt 0 failed
stderr | src/recovering-from-errors/practice/ResetKeysApp.test.tsx > resetKeys resets the boundary automatically > resets as soon as the key prop changes, even though Fallback's Retry button is never clicked
Error: attempt 1 failed
at FlakyUntilAttempt2 (/root/react-app/src/recovering-from-errors/practice/ResetKeysApp.tsx:8:11)
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]: 'attempt 1 failed'
}
The above error occurred in the <FlakyUntilAttempt2> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
stderr | src/recovering-from-errors/practice/ResetKeysApp.test.tsx > resetKeys resets the boundary automatically > resets as soon as the key prop changes, even though Fallback's Retry button is never clicked
after attempt 1: Error: attempt 1 failed
✓ src/recovering-from-errors/practice/ResetKeysApp.test.tsx > resetKeys resets the boundary automatically > resets as soon as the key prop changes, even though Fallback's Retry button is never clicked 327ms
Test Files 1 passed (1)
Tests 1 passed (1)
Bumping attempt from 0 to 1 still shows the fallback — attempt 1 failed — because FlakyUntilAttempt2 still throws below 2. Bumping it again to 2 finally renders Loaded, and the assertion passes. At no point does the test click anything inside the fallback: resetKeys={[attempt]} makes ErrorBoundary compare that array to its previous value on every render and call resetErrorBoundary itself the moment an entry changes, the same comparison Suspense’s key prop uses to force a remount — except here it clears an error state rather than a loaded one, and it’s ErrorBoundary doing the comparing, not React’s own reconciler.
4. Error Boundaries found that a plain class boundary never catches an error thrown from onClick. Use react-error-boundary’s useErrorBoundary hook to route one there on purpose.
Solution
// src/recovering-from-errors/practice/ClickDanger.tsx
import { useErrorBoundary } from "react-error-boundary";
export function ClickDanger() {
const { showBoundary } = useErrorBoundary();
return (
<button
onClick={() => {
showBoundary(new Error("boom from onClick, routed manually"));
}}
>
Break
</button>
);
}
// src/recovering-from-errors/practice/ClickDangerApp.tsx
import { ErrorBoundary } from "react-error-boundary";
import { ClickDanger } from "./ClickDanger.js";
import { Fallback } from "../Fallback.js";
export function ClickDangerApp() {
return (
<ErrorBoundary FallbackComponent={Fallback} onReset={() => console.error("onReset")}>
<ClickDanger />
</ErrorBoundary>
);
}
// src/recovering-from-errors/practice/ClickDangerApp.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ClickDangerApp } from "./ClickDangerApp.js";
describe("useErrorBoundary's showBoundary from an onClick handler", () => {
it("routes an event-handler error into the nearest boundary", async () => {
const user = userEvent.setup();
render(<ClickDangerApp />);
console.error("still rendered before click:", screen.getByRole("button", { name: "Break" }).textContent);
await user.click(screen.getByRole("button", { name: "Break" }));
console.error("alert after click:", screen.getByRole("alert").querySelector("p")!.textContent);
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-recovering-from-errors-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/recovering-from-errors/practice/ClickDangerApp.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/recovering-from-errors/practice/ClickDangerApp.test.tsx > useErrorBoundary's showBoundary from an onClick handler > routes an event-handler error into the nearest boundary
still rendered before click: Break
stderr | src/recovering-from-errors/practice/ClickDangerApp.test.tsx > useErrorBoundary's showBoundary from an onClick handler > routes an event-handler error into the nearest boundary
Error: boom from onClick, routed manually
at onClick (/root/react-app/src/recovering-from-errors/practice/ClickDanger.tsx:10:22)
at executeDispatch (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:20804:9)
at runWithFiberInDEV (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:1048:13)
at processDispatchQueue (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:20854:19)
at /root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:21423:9
at batchedUpdates$1 (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:3437:40)
at dispatchEventForPluginEventSystem (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:21008:7)
at dispatchEvent (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:26142:11)
at dispatchDiscreteEvent (/root/react-app/node_modules/react-dom/cjs/react-dom-client.development.js:26110:11)
at HTMLDivElement.callTheUserObjectsOperation (/root/react-app/node_modules/jsdom/lib/generated/idl/EventListener.js:26:30) {
[stack]: [Getter/Setter],
[message]: 'boom from onClick, routed manually'
}
The above error occurred in the <ClickDanger> component.
React will try to recreate this component tree from scratch using the error boundary you provided, y.
stderr | src/recovering-from-errors/practice/ClickDangerApp.test.tsx > useErrorBoundary's showBoundary from an onClick handler > routes an event-handler error into the nearest boundary
alert after click: Error: boom from onClick, routed manually
✓ src/recovering-from-errors/practice/ClickDangerApp.test.tsx > useErrorBoundary's showBoundary from an onClick handler > routes an event-handler error into the nearest boundary 222ms
Test Files 1 passed (1)
Tests 1 passed (1)
alert after click: Error: boom from onClick, routed manually — the fallback shows, with no render error anywhere; ClickDanger itself never throws. showBoundary hands the nearest ErrorBoundary an error exactly as if it had come from a render, which is the actual fix the library’s docs point to for event-handler errors: not something ErrorBoundary catches on its own, but something useErrorBoundary lets a handler report into it deliberately.