allback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
onError?: (error: Error, errorInfo: ErrorInfo) => void;
resetKeys?: unknown[];
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
reset = () => {
this.setState({ hasError: false, error: null });
};
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (this.props.resetKeys) {
const prev = prevProps.resetKeys ?? [];
const curr = this.props.resetKeys;
const hasChanged = curr.some((key, i) => key !== prev[i]);
if (hasChanged && this.state.hasError) {
this.reset();
}
}
}
render() {
if (this.state.hasError) {
const fallback = this.props.fallback;
return typeof fallback === 'function'
? fallback(this.state.error!, this.reset)
: fallback;
}
return this.props.children;
}
}
### Step 2: Create a Functional Wrapper for Modern DX
Class components cannot use hooks. Wrap the boundary to enable functional composition without losing React’s error catching semantics.
```typescript
import { useRef, useEffect, useMemo } from 'react';
export function useErrorBoundary({
fallback,
onError,
resetKeys,
}: Omit<ErrorBoundaryProps, 'children'>) {
const boundaryRef = useRef<{ reset: () => void } | null>(null);
const reset = useMemo(() => () => boundaryRef.current?.reset(), []);
useEffect(() => {
if (resetKeys?.length) {
reset();
}
}, resetKeys);
const Boundary = useMemo(
() =>
({ children }: { children: ReactNode }) => (
<ErrorBoundary
fallback={fallback}
onError={onError}
resetKeys={resetKeys}
>
{children}
</ErrorBoundary>
),
[fallback, onError, resetKeys]
);
return { Boundary, reset };
}
Step 3: Handle Asynchronous Errors
React error boundaries do not catch promise rejections or async function errors. Wrap async operations explicitly or delegate to a dedicated async boundary.
export function withAsyncBoundary<P extends object>(
Component: React.ComponentType<P>,
fallback: ReactNode
) {
return function AsyncBoundaryWrapper(props: P) {
const [error, setError] = React.useState<Error | null>(null);
React.useEffect(() => {
const handler = (event: PromiseRejectionEvent) => {
if (event.reason instanceof Error) {
setError(event.reason);
}
};
window.addEventListener('unhandledrejection', handler);
return () => window.removeEventListener('unhandledrejection', handler);
}, []);
if (error) {
return fallback;
}
return <Component {...props} />;
};
}
Step 4: Integrate Telemetry
Log errors with component stack traces to your APM. Never swallow errors.
const reportToSentry = (error: Error, errorInfo: ErrorInfo) => {
if (typeof window !== 'undefined' && window.Sentry) {
window.Sentry.withScope((scope) => {
scope.setContext('react', { componentStack: errorInfo.componentStack });
window.Sentry.captureException(error);
});
}
};
Architecture Decisions & Rationale
- Hierarchical over Monolithic: Place boundaries at logical feature boundaries (e.g., sidebar, data grid, checkout form). A root boundary should only catch catastrophic failures.
- Reset Key Pattern: State recovery requires explicit reset triggers.
resetKeys allows parent components to signal that transient conditions (e.g., network recovery, form submission) are resolved.
- Separation of Concerns: The boundary class handles detection and state. Fallback UI lives in separate components. Telemetry is injected via callbacks. This prevents boundary bloat and enables testing.
- Async Boundary Delegation: Native boundaries cannot intercept microtask rejections. The
withAsyncBoundary HOC or a library like react-error-boundary is mandatory for data fetching, event handlers, and timers.
Pitfall Guide
-
Using try/catch for Rendering Errors
try/catch only wraps synchronous execution. React’s render phase runs outside your call stack. Errors thrown in JSX or useEffect will bypass try/catch entirely. Always use boundaries for UI rendering, try/catch for imperative logic.
-
Deploying a Single Global Boundary
A root-level boundary catches everything but provides zero localization. You lose the ability to keep the navigation, header, or dashboard functional while a widget fails. Granular placement is non-negotiable for production resilience.
-
Forgetting State Reset on Retry
Boundaries catch errors but do not automatically clear stale state. If a component fails due to invalid props, retrying without resetting those props causes an immediate re-crash. Implement resetKeys or explicit reset callbacks that clear local state before re-rendering.
-
Assuming Boundaries Catch Async Errors
Promise rejections, async/await errors, and event handler failures do not trigger componentDidCatch. They require window.addEventListener('unhandledrejection') or explicit try/catch around async functions. Libraries like react-error-boundary provide ErrorBoundary with fallbackRender and onReset hooks that simplify this.
-
Heavy Fallback Components Causing Re-render Loops
Fallback UI should be static or lightweight. If the fallback triggers state updates, fetches data, or mounts complex trees, it can cause infinite error cycles. Keep fallbacks to skeleton loaders, error messages, and retry buttons.
-
Misusing getDerivedStateFromError vs componentDidCatch
getDerivedStateFromError must be pure and synchronous. It only updates state to render the fallback. componentDidCatch is for side effects (logging, analytics). Calling async operations or state updates in getDerivedStateFromError breaks React’s reconciliation contract.
-
Skipping Error Serialization for APM
Raw Error objects contain non-enumerable properties and circular references. When sending to Sentry, Datadog, or custom endpoints, serialize carefully. Include error.message, error.stack, and errorInfo.componentStack. Never send the entire Error object to network payloads.
Best Practices from Production:
- Keep boundaries thin. They should only manage error state and delegate to fallbacks.
- Use
resetKeys tied to route changes or data fetch cycles.
- Mock boundaries in tests using
jest.mock or render them with @testing-library/react to verify fallback rendering.
- Audit bundle size: boundaries add ~1.2KB gzipped. Granular placement does not increase bundle size, only component tree depth.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-page marketing site | Global boundary + simple fallback | Low interactivity; crash tolerance is acceptable | Low dev overhead |
| Data-heavy dashboard | Granular boundaries per widget | Isolates grid/chart failures; preserves navigation | Medium setup, high retention gain |
| Form-heavy application | Boundary + reset keys on submission | Clears invalid state on retry; prevents re-crash loops | Low overhead, high UX improvement |
| Async-heavy SPA (fetches, websockets) | Library-driven (react-error-boundary) + async wrapper | Native boundaries miss promise rejections; library handles microtasks | Low overhead, prevents silent failures |
| Legacy class-component codebase | Direct class boundary implementation | No hooks available; class boundary is native and stable | Zero migration cost |
Configuration Template
// src/components/ErrorBoundary.tsx
import React, { ErrorInfo, ReactNode } from 'react';
import { reportError } from '@/lib/telemetry';
interface Props {
fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
resetKeys?: unknown[];
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
reportError(error, errorInfo);
}
reset = () => this.setState({ hasError: false, error: null });
componentDidUpdate(prevProps: Props) {
if (this.props.resetKeys) {
const prev = prevProps.resetKeys ?? [];
const curr = this.props.resetKeys;
if (curr.some((k, i) => k !== prev[i]) && this.state.hasError) {
this.reset();
}
}
}
render() {
if (this.state.hasError) {
const fallback = this.props.fallback;
return typeof fallback === 'function'
? fallback(this.state.error!, this.reset)
: fallback;
}
return this.props.children;
}
}
// src/components/FallbackError.tsx
export function FallbackError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div role="alert" className="p-4 border rounded bg-red-50 text-red-800">
<h2 className="font-semibold">Something went wrong</h2>
<pre className="mt-2 text-sm whitespace-pre-wrap">{error.message}</pre>
<button
onClick={reset}
className="mt-3 px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700"
>
Try again
</button>
</div>
);
}
Quick Start Guide
- Install dependencies:
npm install react-error-boundary (or use the class template above for zero-dependency setup)
- Wrap a feature subtree:
<ErrorBoundary fallback={<FallbackError />}>
<DataGrid />
</ErrorBoundary>
- Add reset keys: Pass
resetKeys={[route, filterValue]} to automatically clear error state when dependencies change
- Wire telemetry: Attach
onError={(err, info) => Sentry.captureException(err, { extra: { componentStack: info.componentStack } })}
- Verify in dev: Inject
throw new Error('test') in a component render, confirm fallback appears, reset works, and error logs to your APM