React Error Boundaries with Claude Code: A Safe Implementation Guide
Implement React error boundaries with Claude Code: catch scope, placement, reset, safe logging, tests, and prompts.
The worst frontend failure is not a single chart or panel crashing. It is a small rendering error turning the entire React app into a blank page. If you ask Claude Code to “add error handling” without constraints, it may add a few try/catch blocks and still leave render-time errors uncovered.
This guide shows how to use Claude Code to implement React Error Boundaries safely. An Error Boundary is a React boundary that catches unexpected errors from its child component tree during rendering and shows fallback UI instead of letting the whole tree collapse. It does not fix the original bug. It limits the blast radius, gives the user a next step, and records enough information for the team to investigate.
Masa tested this on an admin dashboard. The first prompt wrapped the whole app in one boundary. That prevented a blank page, but one broken chart hid the settings and billing views, and the log payload included URLs with email addresses in query strings. After the prompt specified placement, reset rules, PII redaction, and tests, Claude Code produced a much easier diff to review.
Anchor The Implementation In React Docs
Before prompting Claude Code, fix the facts. The official React Component reference explains the two lifecycle pieces: use static getDerivedStateFromError to switch to fallback state, and use componentDidCatch for side effects such as logging. React’s official error-boundaries lint documentation also makes the key point that normal try/catch around JSX is not the right tool for render errors.
The practical constraint is simple: Error Boundaries do not catch everything. They catch unexpected errors thrown by descendant components while React is rendering, committing lifecycle work, or evaluating code that runs as part of rendering. They do not catch click handlers, timers, ordinary promise rejections, server rendering failures, or errors thrown by the boundary itself.
Give Claude Code this table before it writes code:
| Location | Caught by Error Boundary | Production handling |
|---|---|---|
| Child component render error | Yes | Show fallback UI and log a redacted payload |
| Error inside a hook or memo used during render | Usually yes | Validate expected failures first; send unexpected exceptions to the boundary |
| Button click or form submit handler | No | Use local try/catch, show field or toast feedback, optionally rethrow through state |
setTimeout, requestAnimationFrame, or a normal promise | No | Handle the promise explicitly and provide a retry path |
| Server-side rendering | No | Use the framework error page, server logs, and HTTP status handling |
| The Error Boundary’s own fallback failing | No | Keep fallback UI simple and add a higher-level boundary |
flowchart TD
A["Child component throws during render"] --> B["Nearest Error Boundary"]
B --> C["User-facing fallback UI"]
B --> D["Redacted error report"]
E["Click handler or setTimeout throws"] --> F["Handle locally or rethrow through state"]
F --> B
Separate Route-Level And Component-Level Boundaries
More boundaries are not automatically better. One boundary around the whole app is too broad, but wrapping every button creates noise and confusing fallback fragments. Ask Claude Code to place boundaries where user recovery and operational diagnosis make sense.
A route-level boundary is the safety net for a page responsibility: dashboard, settings, billing, editor, search, or admin audit log. It should reset when navigation changes so an error from the previous route does not follow the user into the next screen.
A component-level boundary protects an independent region inside a page. Good examples are a revenue chart, notification panel, Markdown preview, recommendation widget, embedded plugin, or expensive JSON viewer. Poor examples are a normal text input, a submit button, a heading, or an icon. Those should be handled by ordinary UI state and validation.
Use three questions:
- Can the user keep working if this region fails?
- Can this region retry, reload, or reset independently?
- Will the log be easier to diagnose if this region has a feature name?
This connects directly to Claude Code testing strategies: the boundary should match the unit you can test and the unit the user can retry.
Copy-Paste Error Boundary Component
The shared Error Boundary itself is still a class component. Your app can remain function-component based; only the boundary shell needs the class lifecycle methods React exposes for this job.
// src/components/error-boundary/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from "react";
export type ErrorBoundaryFallbackProps = {
error: Error;
resetErrorBoundary: () => void;
};
type ErrorBoundaryProps = {
children: ReactNode;
fallback?: ReactNode | ((props: ErrorBoundaryFallbackProps) => ReactNode);
onError?: (error: Error, info: ErrorInfo) => void;
onReset?: () => void;
resetKeys?: ReadonlyArray<unknown>;
};
type ErrorBoundaryState = {
error: Error | null;
};
function normalizeError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : "Unknown render error");
}
function changedArray(
previous: ReadonlyArray<unknown> = [],
next: ReadonlyArray<unknown> = [],
): boolean {
return (
previous.length !== next.length ||
previous.some((item, index) => !Object.is(item, next[index]))
);
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { error: normalizeError(error) };
}
componentDidCatch(error: Error, info: ErrorInfo) {
this.props.onError?.(normalizeError(error), info);
}
componentDidUpdate(previousProps: ErrorBoundaryProps) {
if (
this.state.error &&
changedArray(previousProps.resetKeys, this.props.resetKeys)
) {
this.resetErrorBoundary();
}
}
resetErrorBoundary = () => {
this.props.onReset?.();
this.setState({ error: null });
};
render() {
if (!this.state.error) return this.props.children;
if (typeof this.props.fallback === "function") {
return this.props.fallback({
error: this.state.error,
resetErrorBoundary: this.resetErrorBoundary,
});
}
if (this.props.fallback) return this.props.fallback;
return (
<section role="alert" aria-labelledby="error-boundary-title">
<h2 id="error-boundary-title">Something went wrong</h2>
<p>Please retry. If the problem continues, contact support.</p>
<button type="button" onClick={this.resetErrorBoundary}>
Try again
</button>
</section>
);
}
}
The fallback can be a static node or a render function. The function form is more useful because it receives the error and a reset callback. Do not print error.stack, raw API responses, or long internal messages in the UI. Users need a concise explanation, a retry action, and sometimes a support reference, not a stack trace.
Fallback UI, Reset, And Retry
Fallback UI is product UI, not a debug dump. It should say what stopped working, whether user data changed, and what action is safe. For chunk-loading failures after a deploy, a full reload is often useful. For a normal component failure, retrying only that region is less disruptive.
// src/components/error-boundary/AppErrorFallback.tsx
import type { ErrorBoundaryFallbackProps } from "./ErrorBoundary";
export function AppErrorFallback({
error,
resetErrorBoundary,
}: ErrorBoundaryFallbackProps) {
const reloadRecommended =
/ChunkLoadError|Loading chunk|dynamically imported module/i.test(
error.message,
);
return (
<section
role="alert"
aria-labelledby="app-error-title"
className="error-fallback"
>
<div>
<p className="error-fallback__eyebrow">This section stopped working</p>
<h2 id="app-error-title">We could not render this part of the page.</h2>
<p>
Your account data was not changed. Retry this section first, then
reload the app if the same message appears again.
</p>
</div>
<div className="error-fallback__actions">
<button type="button" onClick={resetErrorBoundary}>
Try again
</button>
{reloadRecommended ? (
<button type="button" onClick={() => window.location.reload()}>
Reload app
</button>
) : null}
</div>
</section>
);
}
/* src/components/error-boundary/error-fallback.css */
.error-fallback {
border: 1px solid #d7dde8;
border-radius: 8px;
padding: 16px;
background: #fff;
color: #1f2937;
}
.error-fallback__eyebrow {
margin: 0 0 4px;
color: #6b7280;
font-size: 0.875rem;
}
.error-fallback h2 {
margin: 0 0 8px;
font-size: 1.125rem;
}
.error-fallback__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
The common retry bug is resetting only the boundary state while leaving the broken input unchanged. If the same props, cache entry, or route state immediately throws again, the retry button looks broken. Use resetKeys for route keys, query parameters, user IDs, refresh counters, or any input that should clear the captured error when it changes.
Route And Component Examples
With React Router, keep the route boundary thin and explicit. The example below resets on navigation and reports a feature name with each captured error. Frameworks such as Next.js and Remix offer route-specific error files, but the design decision is the same: reset on route change and keep page-level failures isolated.
// src/AppRoutes.tsx
import { lazy, ReactNode, Suspense } from "react";
import {
createBrowserRouter,
RouterProvider,
useLocation,
} from "react-router-dom";
import { ErrorBoundary } from "./components/error-boundary/ErrorBoundary";
import { AppErrorFallback } from "./components/error-boundary/AppErrorFallback";
import { currentErrorContext, reportReactError } from "./lib/error-reporting";
import { Layout } from "./routes/Layout";
const DashboardPage = lazy(() => import("./routes/DashboardPage"));
const SettingsPage = lazy(() => import("./routes/SettingsPage"));
function RouteBoundary({
children,
feature,
}: {
children: ReactNode;
feature: string;
}) {
const location = useLocation();
return (
<ErrorBoundary
resetKeys={[location.key]}
fallback={(props) => <AppErrorFallback {...props} />}
onError={(error, info) => {
void reportReactError(
error,
info.componentStack,
currentErrorContext(feature),
);
}}
>
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
</ErrorBoundary>
);
}
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{
path: "dashboard",
element: (
<RouteBoundary feature="dashboard">
<DashboardPage />
</RouteBoundary>
),
},
{
path: "settings",
element: (
<RouteBoundary feature="settings">
<SettingsPage />
</RouteBoundary>
),
},
],
},
]);
export function AppRoutes() {
return <RouterProvider router={router} />;
}
Component-level boundaries belong around independently recoverable regions: chart widgets, Markdown preview, recommendation panels, third-party embeds, and JSON viewers. They do not belong around every input field. A payment failure, validation error, or expired session is a normal product state and should be shown by the form itself.
Async And Event Handler Failures
Error Boundaries do not automatically catch click handlers or ordinary async failures. Expected failures should stay local: validation errors near fields, authentication errors in the login flow, and payment declines inside the payment UI. Unexpected exceptions can be handed to the nearest boundary by storing them in state and throwing during the next render.
// src/components/error-boundary/useAsyncBoundary.ts
import { useCallback, useState } from "react";
function toError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : "Unknown async error");
}
export function useAsyncBoundary() {
const [error, setError] = useState<Error | null>(null);
if (error) {
throw error;
}
return useCallback((value: unknown) => {
setError(toError(value));
}, []);
}
// src/components/settings/SaveButton.tsx
import { useState } from "react";
import { useAsyncBoundary } from "../error-boundary/useAsyncBoundary";
type SaveButtonProps = {
onSave: () => Promise<void>;
};
export function SaveButton({ onSave }: SaveButtonProps) {
const [pending, setPending] = useState(false);
const throwToBoundary = useAsyncBoundary();
async function handleClick() {
setPending(true);
try {
await onSave();
} catch (error) {
throwToBoundary(error);
} finally {
setPending(false);
}
}
return (
<button type="button" disabled={pending} onClick={handleClick}>
{pending ? "Saving..." : "Save"}
</button>
);
}
Tell Claude Code not to send every async failure to the boundary. A 400 response, an invalid field, or a known rate limit should use local UI. The boundary is for unexpected exceptions, corrupted responses, unsafe rendering assumptions, and bugs that would otherwise blank the page.
Log Without Leaking PII
PII means personally identifiable information: email addresses, phone numbers, names, addresses, tokens, card numbers, free-text support messages, and similar data. componentDidCatch is a good place to report a client error, but only after the payload is constrained and redacted.
Log feature name, release, route pathname, error name, redacted message, stack, and component stack. Do not send query strings, form values, cookies, Authorization headers, raw API responses, or the full current URL.
// src/lib/error-reporting.ts
type ClientErrorContext = {
route: string;
release: string;
feature?: string;
userHash?: string;
};
const REDACTIONS: Array<[RegExp, string]> = [
[/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted-email]"],
[/\b(?:\d[ -]*?){13,19}\b/g, "[redacted-number]"],
[/\b(token|secret|password|authorization)=([^&\s]+)/gi, "$1=[redacted]"],
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]"],
];
export function redactText(value: string | undefined): string | undefined {
if (!value) return value;
return REDACTIONS.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value,
);
}
export function currentErrorContext(feature?: string): ClientErrorContext {
const env = (import.meta as unknown as {
env?: Record<string, string | undefined>;
}).env;
return {
route: typeof window === "undefined" ? "server" : window.location.pathname,
release: env?.VITE_APP_VERSION ?? "dev",
feature,
};
}
export async function reportReactError(
error: Error,
componentStack: string | undefined,
context: ClientErrorContext,
) {
const payload = {
name: redactText(error.name) ?? "Error",
message: redactText(error.message) ?? "Unknown error",
stack: redactText(error.stack),
componentStack: redactText(componentStack),
route: context.route,
release: context.release,
feature: context.feature,
userHash: context.userHash,
};
const body = JSON.stringify(payload);
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
const sent = navigator.sendBeacon(
"/api/client-errors",
new Blob([body], { type: "application/json" }),
);
if (sent) return;
}
await fetch("/api/client-errors", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "omit",
keepalive: true,
body,
});
}
Redact again on the server. Client-side redaction is useful, but it is not a compliance boundary. Ask Claude Code to implement both layers and to keep only a hashed user identifier if support correlation is required.
Tests And Verification Commands
An Error Boundary only matters when something breaks, so test the broken path. The minimum suite checks fallback rendering, the onError callback, and retry reset.
// src/components/error-boundary/ErrorBoundary.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { ReactNode, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "./ErrorBoundary";
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error("profile widget crashed");
}
return <p>Profile loaded</p>;
}
function RetryHarness({ onError }: { onError: ReturnType<typeof vi.fn> }) {
const [broken, setBroken] = useState(true);
return (
<ErrorBoundary
onError={onError}
fallback={({ resetErrorBoundary }) => (
<button
type="button"
onClick={() => {
setBroken(false);
resetErrorBoundary();
}}
>
Retry profile
</button>
)}
>
<Bomb shouldThrow={broken} />
</ErrorBoundary>
);
}
function StaticFallback({ children }: { children: ReactNode }) {
return (
<ErrorBoundary fallback={<p>Could not load this panel.</p>}>
{children}
</ErrorBoundary>
);
}
describe("ErrorBoundary", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it("renders fallback UI when a child throws", () => {
render(
<StaticFallback>
<Bomb shouldThrow />
</StaticFallback>,
);
expect(screen.getByText("Could not load this panel.")).toBeInTheDocument();
});
it("calls onError with the thrown error and component stack", () => {
const onError = vi.fn();
render(<RetryHarness onError={onError} />);
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0].message).toBe("profile widget crashed");
expect(onError.mock.calls[0][1].componentStack).toContain("Bomb");
});
it("can reset and render children again", async () => {
const user = userEvent.setup();
const onError = vi.fn();
render(<RetryHarness onError={onError} />);
await user.click(screen.getByRole("button", { name: "Retry profile" }));
expect(screen.getByText("Profile loaded")).toBeInTheDocument();
});
});
Use these commands as the handoff checklist. Adjust script names to match your repository.
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
npm run typecheck
npx vitest run src/components/error-boundary/ErrorBoundary.test.tsx
npm run build
Prompts To Give Claude Code
Use a constrained implementation prompt:
Add React Error Boundaries to this React + TypeScript app.
Constraints:
- Follow the official React Error Boundary model.
- Catch render errors from descendants, but handle event handlers and ordinary async failures separately.
- Implement a shared ErrorBoundary class, user-facing fallback UI, and reportReactError with PII redaction.
- Route-level boundaries must reset on navigation through resetKeys.
- Component-level boundaries should only wrap independent regions such as DashboardChart, MarkdownPreview, and RecommendationPanel.
- Do not log error.stack, query strings, form values, Authorization headers, cookies, or raw API responses without redaction.
- Add Vitest + Testing Library coverage for fallback UI, onError, and retry reset.
- Run npm run typecheck, npx vitest run, and npm run build, then report the results.
Read the existing routing, logging, and CSS conventions first. Keep the diff minimal.
Then ask for review before merging:
Review this diff only from the Error Boundary perspective.
List issues with boundary placement, async errors that are not caught, PII leakage, missing resetKeys, fallback accessibility, and missing tests.
Do not change code. Return file names and line numbers.
Practical Use Cases And Pitfalls
Use case one is a SaaS dashboard. Wrap the revenue chart, active users table, notification panel, and third-party embed independently. A chart library bug should not block settings or billing. Use feature names such as dashboard.revenue-chart in logs.
Use case two is a content editor. The Markdown preview, image preview, and AI summary panel are risky regions. The body editor and save button are core work surfaces, so preserve them. Save failures should stay in the event handler and show field-level or toast feedback.
Use case three is ecommerce or signup. Do not treat card declines, stock shortages, or validation failures as boundary errors. Those are expected product states. Boundary-wrap optional recommendation modules, campaign banners, and review widgets instead.
Use case four is an admin audit log. A JSON viewer for large payloads can throw during formatting. Wrap the viewer, not the entire admin page, so the operator can still change filters, export CSV, or inspect another user.
The common pitfalls are predictable: relying on try/catch around JSX, sending all async failures to the boundary, logging full URLs with query strings, printing stack traces in the UI, resetting the boundary without changing the broken input, and wrapping so many tiny components that the page becomes a patchwork of fallback messages.
For teams, turn the implementation and review prompts into reusable Claude Code commands. If you want to standardize this across a codebase, connect the article to Claude Code training and implementation support so the next step is operational, not just educational.
Summary
An Error Boundary is not a universal exception handler. It is a React-specific boundary for render failures, with fallback UI and safe logging. When you use Claude Code, specify the catch scope, route-level and component-level placement, reset behavior, PII policy, tests, and verification commands in the prompt.
In the hands-on dashboard test, defining resetKeys and redaction rules before asking for code made the diff much easier to review. The app no longer failed as a whole when one widget crashed, and the logs were useful without exposing user data.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Receipt Pattern: Record Scope, Proof, and Rollback
A permission receipt pattern for Claude Code: allowed actions, approval boundaries, proof commands, rollback, and revenue CTA checks.
Safe Agent Harness Design for Claude Code and Codex: Permissions, Checks, and Rollback
Build a practical agent harness for Claude Code and Codex with policy, planning, verification, and recovery layers.
Claude Code Subagents: A Practical Guide to Safe Agent Delegation
Claude Code subagent guide for safe parallel article and code work: delegation rules, prompts, pitfalls, and checks.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.