This page is part of the React Error Boundary Implementation cluster, which covers the full lifecycle of boundary placement, fallback design, and recovery within React applications.
The exact problem: a single boundary class used everywhere, but it accepts any #
Most codebases reach for a boundary component early, copy a snippet from the React docs, and ship it. The docs example types the caught error as any, which compiles cleanly but breaks two things immediately: downstream telemetry schemas lose their shape guarantees, and fallback components cannot discriminate between a render crash and an SDK initialization failure without a cast. The concrete failure mode is a production TypeError: Cannot read properties of undefined (reading 'message') inside the fallback renderer, thrown because the error payload shape was not what the fallback assumed.
The fix is a single, generic boundary class with a discriminated union for payloads, strict interface contracts on the fallback, and a resetKeys mechanism so navigation automatically clears the error state without a full page reload.
Component lifecycle: how errors flow through the boundary #
The diagram below shows the two static lifecycle hooks that make a boundary work, and where each fires relative to the React render pipeline. Understanding this ordering is essential before touching the code.
The key constraint: getDerivedStateFromError is a pure static method — no side-effects. It runs synchronously during React’s render phase to determine what to display. componentDidCatch fires after the commit, giving you access to errorInfo.componentStack and making it safe to call external APIs. This ordering means your telemetry call will always have the component stack available, but your state will already have been updated before that call runs.
Zero-to-working implementation #
The snippet below is the minimal complete boundary. It can be dropped into any React 16.3+ project and composed without modification across the entire application.
import {
Component,
type ComponentType,
type ErrorInfo,
type ReactNode,
} from 'react';
// --- Error payload types ---
// Discriminated union lets callers narrow by error.type in switch statements.
export type AppError =
| { type: 'render'; message: string; stack?: string }
| { type: 'lifecycle'; message: string; stack?: string; componentStack: string }
| { type: 'unknown'; raw: string };
// Converts an unknown thrown value to a typed AppError.
function toAppError(err: unknown): AppError {
if (err instanceof Error) {
return { type: 'render', message: err.message, stack: err.stack };
}
return { type: 'unknown', raw: String(err) };
}
// --- Fallback contract ---
export interface FallbackProps {
error: AppError;
resetErrorBoundary: () => void;
}
// --- Boundary props ---
export interface ErrorBoundaryProps {
/** Component rendered when an error is caught. */
fallback: ComponentType<FallbackProps>;
children: ReactNode;
/** Called after componentDidCatch — use for telemetry. */
onError?: (error: AppError) => void;
/**
* When any value in this array changes and the boundary is in an error
* state, the boundary resets automatically. Wire to the current route key.
*/
resetKeys?: unknown[];
}
interface BoundaryState {
hasError: boolean;
error: AppError | null;
/** Snapshot of resetKeys taken when the error was caught. */
errorResetKeys: unknown[];
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, BoundaryState> {
state: BoundaryState = { hasError: false, error: null, errorResetKeys: [] };
// Runs synchronously during render — must be pure.
static getDerivedStateFromError(err: unknown): Partial<BoundaryState> {
return { hasError: true, error: toAppError(err) };
}
// Runs after commit — safe to call external APIs here.
componentDidCatch(err: Error, info: ErrorInfo) {
const payload: AppError = {
type: 'lifecycle',
message: err.message,
stack: err.stack,
componentStack: info.componentStack ?? '',
};
this.setState({ error: payload, errorResetKeys: this.props.resetKeys ?? [] });
this.props.onError?.(payload);
}
// Auto-reset when resetKeys change (e.g. route navigation).
static getDerivedStateFromProps(
props: ErrorBoundaryProps,
state: BoundaryState,
): Partial<BoundaryState> | null {
if (!state.hasError) return null;
const keys = props.resetKeys ?? [];
const changed = keys.some((k, i) => k !== state.errorResetKeys[i]);
if (changed) return { hasError: false, error: null, errorResetKeys: [] };
return null;
}
resetErrorBoundary = () => {
this.setState({ hasError: false, error: null, errorResetKeys: [] });
};
render() {
if (this.state.hasError && this.state.error) {
const Fallback = this.props.fallback;
return (
<Fallback
error={this.state.error}
resetErrorBoundary={this.resetErrorBoundary}
/>
);
}
return this.props.children;
}
}
Step-by-step walkthrough #
1. Define AppError as a discriminated union. Each variant carries only the fields that exist for that error origin. The type field is the discriminant, so a switch (error.type) in the fallback narrows the payload to the exact shape without casts.
2. Write toAppError(err: unknown): AppError. TypeScript 4+ types caught errors as unknown. This helper centralises the instanceof Error guard so neither getDerivedStateFromError nor componentDidCatch touches raw unknown directly. Every call-site stays clean.
3. Type FallbackProps explicitly. The fallback receives a fully typed AppError and a resetErrorBoundary callback — nothing more. This contract lets you swap fallback components without touching the boundary class.
4. Implement getDerivedStateFromError as a pure static. It must not call setState, dispatch Redux actions, or trigger side-effects. Return only the state slice that needs updating. React may call this multiple times during concurrent rendering.
5. Implement componentDidCatch for telemetry. This is the correct place to enrich the payload with info.componentStack and call your logging endpoint. Emitting telemetry here keeps getDerivedStateFromError free of I/O concerns, which is required in React’s concurrent mode.
6. Implement getDerivedStateFromProps for automatic reset. Compare the current resetKeys prop against the snapshot captured when the error was caught (state.errorResetKeys). When they differ, return the clean state. This wires route navigation to boundary recovery without manual intervention.
Edge cases #
| Scenario | What breaks | Mitigation |
|---|---|---|
| Fallback component itself throws | Uncaught error propagates to the nearest parent boundary | Wrap the fallback in a second minimal boundary with a static HTML fallback |
resetKeys array reference changes on every render |
Boundary resets on each render, looping | Memoize with useMemo or define the array outside the render function |
| React 18 Strict Mode double-invocation | getDerivedStateFromError may fire twice with the same error |
State update is idempotent — no action needed; do not rely on call count |
Cross-origin <script> errors |
error.message is "Script error.", stack is empty |
Add crossorigin="anonymous" to script tags and set CORS headers on the asset server |
Verification steps #
Browser DevTools. In React DevTools, select the boundary component and inspect its state. After triggering an error in a child, hasError should be true and error.type should be 'lifecycle' with a non-empty componentStack. After clicking the reset button, hasError reverts to false without a page reload.
Console log pattern. Add a temporary console.log('[boundary]', payload) to componentDidCatch. The logged object should match the AppError union shape exactly — no undefined keys, no any leakage.
Automated assertion. Use @testing-library/react with a component that throws:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ErrorBoundary } from './ErrorBoundary';
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) throw new Error('test crash');
return <span>ok</span>;
}
function Fallback({ resetErrorBoundary }: { resetErrorBoundary: () => void }) {
return <button onClick={resetErrorBoundary}>reset</button>;
}
test('renders fallback and resets', async () => {
const user = userEvent.setup();
const { rerender } = render(
<ErrorBoundary fallback={Fallback}>
<Bomb shouldThrow={true} />
</ErrorBoundary>,
);
expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /reset/i }));
rerender(
<ErrorBoundary fallback={Fallback}>
<Bomb shouldThrow={false} />
</ErrorBoundary>,
);
expect(screen.getByText('ok')).toBeInTheDocument();
});
Suppress the expected console error in the test environment with jest.spyOn(console, 'error').mockImplementation(() => {}) to keep output readable.
FAQ #
Why must an error boundary be a class component — can a function component do the same?
React only exposes getDerivedStateFromError and componentDidCatch on class components. There is no hooks equivalent for catching render-phase errors; useEffect cannot intercept synchronous render exceptions. A thin function component wrapper around the class is the standard composition pattern.
How do I force the boundary to reset when the user navigates to a new route?
Pass a resetKeys prop containing values derived from the current route (e.g. [location.pathname]). getDerivedStateFromProps compares the current keys against the snapshot stored when the error was caught; when they differ and the boundary is in an error state, it returns clean state automatically. Alternatively, key the boundary on the route string so React fully remounts it: <ErrorBoundary key={location.pathname}>.
Does TypeScript’s unknown type for caught errors break existing tooling?
Only if downstream code was typed with any. Narrowing with instanceof Error before accessing .message or .stack is the correct fix. Centralising that narrowing in toAppError means every call-site receives a typed AppError and the tooling — including discriminated union exhaustiveness checks in switch statements — works as expected.
Related #
- React Error Boundary Implementation — parent cluster covering boundary placement, fallback design, and testing strategies
- Error Propagation Strategies — how uncaught render errors travel through the component tree before reaching a boundary
- Fallback UI Rendering Patterns — designing fallback components that avoid layout shift when the boundary activates
- State Reset Cleanup Protocols — complementary techniques for tearing down stale state after recovery so the remounted subtree starts clean