Catching Errors Gracefully
An uncaught error in a React component destroys the entire component tree and shows the user a blank screen. Error Boundaries are the mechanism that isolates JavaScript errors in rendering, shows fallback UIs and keeps the application stable, when implemented correctly, reliably and with monitoring integration.
Table of Contents
- 1. What happens without Error Boundaries
- 2. How Error Boundaries work
- 3. Implementing your own Error Boundary class
- 4. Granularity: where to place Error Boundaries?
- 5. Fallback UI: what makes a good error message
- 6. Reset mechanisms: how the user recovers
- 7. Error monitoring: integration with Sentry and friends
- 8. What Error Boundaries do not catch
- 9. Custom implementation vs. react-error-boundary
- 10. Summary
- 11. FAQ
1. What happens without Error Boundaries
Without Error Boundaries, a JavaScript error while rendering a React component has catastrophic consequences: React unmounts the entire component tree from the root. The user sees a blank screen or the browser console with an error, both are unacceptable for a production application. In development, React 18 shows a red error overlay that disappears at build time. Without Error Boundaries, there is no fallback at all in production, only the blank screen.
The problem occurs more often than one might think: an API endpoint returns unexpected data, a component tries to read undefined.length and throws a TypeError. Or an npm package update changes an API that a child component uses. Or the browser cache serves stale JavaScript that is no longer compatible with the current API response. These errors are hard to prevent completely in practice, but with Error Boundaries you can limit their impact to the broken part of the application.
Error Boundaries are React class components with two special lifecycle methods: static getDerivedStateFromError and componentDidCatch. This is one of the few cases in modern React where a class component has no hook-based alternative, there is no useErrorBoundary hook in React itself. For most projects the solution is: write a reusable Error Boundary class once, or use the react-error-boundary library, which does the same thing.
2. How Error Boundaries work
static getDerivedStateFromError(error) is called when an error is thrown in the component tree below the Error Boundary. This method receives the error and returns a new state object that React uses for the next render. The typical pattern: return { hasError: true, error }. On the next render, the render method checks this.state.hasError and either shows the fallback UI or the normal children.
componentDidCatch(error, errorInfo) is called after the fallback UI has rendered. It receives the error and an ErrorInfo object containing the componentStack, a stack trace of the React component hierarchy down to the error. This is the right place for side effects: logging, sending data to monitoring services like Sentry, updating analytics events. getDerivedStateFromError is a static method and must not perform side effects.
// error-boundary.tsx: Production-ready Error Boundary with TypeScript
import { Component, type ReactNode, type ErrorInfo } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorId: string | null;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
onError?: (error: Error, errorInfo: ErrorInfo) => void;
onReset?: () => void;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, errorId: null };
this.reset = this.reset.bind(this);
}
// Called synchronously after a child throws, update state to show fallback
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error,
errorId: `err-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
};
}
// Called after render, good place for side effects like logging
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
const { onError } = this.props;
// Log component stack for debugging
console.error('[ErrorBoundary] Caught error:', error, errorInfo.componentStack);
// Delegate to consumer for monitoring integration
onError?.(error, errorInfo);
}
reset(): void {
this.props.onReset?.();
this.setState({ hasError: false, error: null, errorId: null });
}
render(): ReactNode {
const { hasError, error } = this.state;
const { children, fallback } = this.props;
if (hasError && error) {
// Support both static ReactNode and render function fallbacks
if (typeof fallback === 'function') {
return fallback(error, this.reset);
}
return fallback ?? <DefaultErrorFallback error={error} onReset={this.reset} />;
}
return children;
}
}
4. Granularity: where to place Error Boundaries?
The placement of Error Boundaries is one of the most important architectural decisions when implementing them. A single Error Boundary at the root of the application protects against a completely blank screen, but does not isolate errors, any error anywhere in the application shows the same global fallback UI and makes the entire application unusable. That is better than a blank screen, but far from the optimal user experience.
The better strategy is granular placement: an Error Boundary at the route level prevents an error in one route from disabling navigation to other routes. An Error Boundary around widget components (news feed, product recommendations, comments) ensures that an error in one widget does not affect the entire page. The rule of thumb: Error Boundaries at every meaningful isolation point, wherever a fallback UI allows the user to keep using the rest of the application. That is typically at the route level and around independent page sections.
// app-router.tsx: Route-level Error Boundaries with React Router v6
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { ErrorBoundary } from './error-boundary';
// Route-level fallback, allows navigation to other routes
function RouteError({ error, onReset }: { error: Error; onReset: () => void }) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="text-center max-w-md px-6">
<div className="text-6xl mb-4">⚠</div>
<h2 className="text-xl font-bold text-slate-800 mb-2">Error loading this page</h2>
<p className="text-slate-600 text-sm mb-6">{error.message}</p>
<div className="flex gap-3 justify-center">
<button onClick={onReset} className="btn-primary">Try again</button>
<a href="/" className="btn-secondary">Back to home</a>
</div>
</div>
</div>
);
}
// Widget-level boundary, isolated, page remains functional
function WidgetErrorFallback() {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700">
This section could not be loaded. The page is still usable.
</div>
);
}
// Wrap each route with its own Error Boundary
function withRouteBoundary(Component: React.ComponentType) {
return (
<ErrorBoundary
fallback={(error, reset) => <RouteError error={error} onReset={reset} />}
onError={(error, info) => reportToMonitoring(error, { context: 'route', componentStack: info.componentStack })}
>
<Component />
</ErrorBoundary>
);
}
// Wrap independent widgets separately
function Dashboard() {
return (
<div className="grid grid-cols-3 gap-6">
<ErrorBoundary fallback={<WidgetErrorFallback />}>
<RevenueWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetErrorFallback />}>
<OrdersWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetErrorFallback />}>
<NewsWidget />
</ErrorBoundary>
</div>
);
}
5. Fallback UI: what makes a good error message
The fallback UI is what the user sees when an error has occurred. A poor fallback UI is a blank area with no explanation, or a cryptic technical error message. A good fallback UI explains in plain language what happened, offers the user a concrete action (reload the page, try again, contact support), and if possible provides a reference number for support that is linked to the monitoring event.
Different levels of granularity need different fallback UIs. A global error (root Error Boundary) justifies a full error page with navigation. A widget error should draw minimal attention and not pull the user out of their reading flow. An inline error message with a retry button is usually better for widget errors than a large error card. The language of the error message should always be aimed at the user, not the developer.
6. Reset mechanisms: how the user recovers
A detail often forgotten with Error Boundaries is the reset mechanism. An Error Boundary that catches an error and shows a fallback UI stays in that error state until it is reset. The user can reload the page (a full reset), but that is a poor experience. Better: the Error Boundary offers a retry button that resets the internal state and attempts to render the children again. If the error was caused by a temporary network failure, this fixes the problem without a page reload.
React Router provides a natural reset trigger: navigating to another route and back automatically resets route-level Error Boundaries, because the component unmounts and remounts. The react-error-boundary library offers the resetKeys prop for this: a list of values whose change automatically resets the Error Boundary. This enables automatic retry when a piece of state that might be responsible for the error changes.
7. Error monitoring: integration with Sentry and friends
Error Boundaries without monitoring integration are only half a feature. You isolate errors from the user, but you do not learn how often they occur, which users are affected, and which error types dominate. Integration with monitoring services like Sentry, Datadog or LogRocket happens in componentDidCatch. For React, Sentry provides the Sentry.captureException function, which can also include the React component stack from errorInfo.componentStack.
One important detail: in the development environment, React 18 shows the same error twice, once caught by the Error Boundary and once by the global error handler. This is due to React's StrictMode behavior: React calls lifecycle methods twice in development mode to surface side effects. In production, componentDidCatch is called exactly once. The monitoring code should avoid duplicates in development with an environment check: if (process.env.NODE_ENV === 'production') { captureException(error); }, or leave the deduplication to Sentry itself.
// monitoring.ts: Centralized error reporting with context
import * as Sentry from '@sentry/react';
import type { ErrorInfo } from 'react';
interface ErrorContext {
context: 'route' | 'widget' | 'global';
componentStack?: string;
userId?: string;
errorId?: string;
}
export function reportToMonitoring(error: Error, context: ErrorContext): void {
// Skip duplicate reports in development (React StrictMode double-invoke)
if (process.env.NODE_ENV !== 'production') {
console.group('[Monitoring - Dev]');
console.error(error);
console.info('Context:', context);
console.groupEnd();
return;
}
Sentry.withScope((scope) => {
scope.setTag('error.context', context.context);
scope.setTag('error.id', context.errorId ?? 'unknown');
if (context.userId) {
scope.setUser({ id: context.userId });
}
if (context.componentStack) {
scope.setExtra('componentStack', context.componentStack);
}
Sentry.captureException(error);
});
}
// use-error-reporting.ts: Hook for manual error reporting in event handlers
export function useErrorReporting() {
return {
reportError: (error: unknown, context?: Partial<ErrorContext>) => {
const err = error instanceof Error ? error : new Error(String(error));
reportToMonitoring(err, { context: 'widget', ...context });
},
};
}
// Global unhandled rejection handling (outside React tree)
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason instanceof Error
? event.reason
: new Error(`Unhandled rejection: ${String(event.reason)}`);
reportToMonitoring(error, { context: 'global' });
});
}
8. What Error Boundaries do not catch
Understanding the limits of Error Boundaries is just as important as understanding their capabilities. Error Boundaries only catch errors that occur during rendering, in the render method, in getDerivedStateFromProps, in constructors of child components, and in React lifecycle methods. They do not catch errors in event handlers, errors in asynchronous code (setTimeout, fetch, async/await), or errors outside the React component tree.
For errors in event handlers, use plain try/catch: const handleClick = () => { try { doSomething(); } catch (err) { setError(err); } }. For errors in asynchronous code inside useEffect, the same rule applies: try/catch in the effect and a state update with the error. React Query and SWR have their own error handling for fetch errors that works independently of Error Boundaries. For globally catching unhandled promise rejections, window.addEventListener('unhandledrejection', ...) is the right mechanism.
9. Custom implementation vs. react-error-boundary
The react-error-boundary library by Brian Vaughn (formerly of the React Core Team) is a slim, well-tested abstraction over the native Error Boundary API. It offers an ErrorBoundary component with a FallbackComponent prop, a fallbackRender prop for render functions, resetKeys for automatic reset, and the useErrorBoundary hook for manually triggering Error Boundaries from function components and event handlers.
The useErrorBoundary hook from react-error-boundary solves a real problem: Error Boundaries only catch rendering errors, not errors in event handlers. With const { showBoundary } = useErrorBoundary(), you can forward an error from an event handler or an async effect to the nearest Error Boundary: showBoundary(error). That is a genuine advantage over the native API. For new projects I recommend react-error-boundary, the roughly 2 KB of additional bundle size is a good investment for the improved DX and proven patterns.
| Feature | Custom class component | react-error-boundary |
|---|---|---|
| Bundle size | 0 KB additional | ~2 KB gzip |
| Reset via resetKeys | Implement manually | Built in |
| Errors from event handlers | try/catch + rethrow in render | useErrorBoundary() hook |
| TypeScript support | Manually typed | Fully typed |
| Maintenance | Your own code | Actively maintained |