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

Retrying 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

react error-boundaries react-error-boundary use vitest

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.

Frequently Asked Questions

What does the react-error-boundary library add over a hand-written class boundary?
It supplies the class component (getDerivedStateFromError, componentDidCatch) so a project doesn't write one by hand, and adds a reset mechanism on top: a resetErrorBoundary function handed to the fallback, an onReset callback to clear whatever caused the error, an onError callback that fires once per catch, and a resetKeys prop that resets automatically when a value changes, with no button at all.
Why does React's own error message name a single letter instead of ErrorBoundary?
React logs whatever the boundary component's own name property reports, and react-error-boundary ships its ErrorBoundary class inside an already-minified bundle, even in its normal (non-production) build. The message still points at the right component in the tree — it just can't recover a readable name the library's own build step already discarded.
Does a promise rejection read with use() get caught by an error boundary the same way a render error does?
Yes. React treats a rejected promise passed to use() the same way it treats a thrown error during render: the nearest error boundary above it catches it, runs its fallback, and (with react-error-boundary) can pass that same error object to onError and to the FallbackComponent's own error prop. Suspense only ever handles the pending case; the error case is a boundary's job either way.
What's the difference between onError and onReset on react-error-boundary's ErrorBoundary?
onError fires exactly once, at the moment the boundary catches an error — the same timing as a class boundary's componentDidCatch. onReset fires only when the boundary actually resets: a Retry button calling resetErrorBoundary, or a value in resetKeys changing. Clicking Retry without fixing the underlying problem calls onReset but not onError again until the retried render throws a new error of its own.
Can react-error-boundary catch an error thrown from an onClick handler?
Not automatically — event handler errors are still outside React's render phase, the same limit a hand-written class boundary has. The library's useErrorBoundary hook provides the actual fix: calling the showBoundary function it returns, with a real Error object, routes that error into the nearest ErrorBoundary exactly as if it had been thrown during render.