Error Boundaries and a Complete Error Handling System
In a React application, a single unhandled JavaScript error can tear down the entire component tree, leaving the user staring at a blank screen with no feedback and no path to recovery. This is not a bug in React—it is a deliberate design decision. React's position is that rendering a corrupted UI is more dangerous than rendering nothing at all. But "failing explicitly" requires engineering support: error boundaries, structured error handling strategies, and monitoring.
Why Unhandled Errors Crash the Entire Tree
During rendering, every component's render function (or function component body) executes synchronously. If one throws an exception, React has no way to continue rendering that component or its children—they are in an undefined state. Before React 16, React would leave whatever UI was displayed at the time of the crash, often a partially-rendered, logically inconsistent state. This was worse than a blank screen because users could continue interacting with broken UI and produce corrupt data.
React 16 introduced error boundaries and established a clear rule: an unhandled error during the render phase unmounts the entire React tree. This forces developers to handle errors proactively rather than hoping a broken UI continues to work.
Implementing an Error Boundary Class Component
Error boundaries must be class components. They depend on two lifecycle methods that have no Hook equivalents in function components (this remains true in React 19).
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode | ((error: Error) => ReactNode);
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
// Static method: derives new state from the error.
// Called during the render phase; must be a pure function with no side effects.
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
// Instance method: runs after the error is confirmed.
// Called during the commit phase (after the DOM has been updated).
// Appropriate for side effects like logging and error reporting.
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
const { fallback } = this.props;
if (typeof fallback === 'function') {
return fallback(this.state.error);
}
return fallback ?? <div>Something went wrong.</div>;
}
return this.props.children;
}
}
The division of labor between the two lifecycle methods is precise. getDerivedStateFromError runs during rendering and must update state synchronously so that React can render the fallback UI. componentDidCatch runs after the commit phase, making it safe to trigger side effects such as calling an error reporting API.
react-error-boundary: A Production-Ready Library
Building a robust error boundary from scratch requires handling many edge cases: state reset, retry logic, and the key reset trick for re-mounting children. The react-error-boundary library handles all of them:
npm install react-error-boundary
Basic Usage
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({
error,
resetErrorBoundary,
}: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert">
<p>Something went wrong: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => {
reportError(error, info); // report to Sentry, Datadog, etc.
}}
onReset={() => {
// reset application state: clear cache, reset store, etc.
}}
>
<UserDashboard />
</ErrorBoundary>
);
}
The useErrorBoundary Hook
react-error-boundary exposes a useErrorBoundary Hook that lets function components programmatically trigger the nearest parent error boundary:
import { useErrorBoundary } from 'react-error-boundary';
function DataFetcher({ userId }: { userId: string }) {
const { showBoundary } = useErrorBoundary();
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch((error) => {
// Route the async error to the nearest ErrorBoundary
showBoundary(error);
});
}, [userId]);
// ...
}
This solves an important problem: error boundaries only catch errors thrown during rendering. Async errors inside useEffect or event handlers are not caught by default. showBoundary gives you a way to route those errors through the boundary system.
Resetting Error Boundaries
When an error is transient (such as a network blip), the user should be able to dismiss the error state and trigger a fresh render. resetKeys provides an elegant mechanism:
function App() {
const [location, setLocation] = useState(window.location.pathname);
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
resetKeys={[location]} // automatically resets when location changes
onReset={() => setLocation(window.location.pathname)}
>
<Router />
</ErrorBoundary>
);
}
Error Boundary Placement Strategy
More boundaries are not always better, and a single global boundary is not sufficient for a real application. The right placement balances fault isolation with user experience.
App
├── GlobalErrorBoundary ← Outermost: catch anything that escapes lower boundaries
│ ├── Header ← No boundary needed: if Header crashes, navigation is gone anyway
│ ├── Sidebar
│ │ └── SidebarErrorBoundary ← Independent region: sidebar crash doesn't affect main content
│ └── Main
│ ├── RouteErrorBoundary ← Route-level: each page is isolated
│ │ └── PageContent
│ │ ├── CardErrorBoundary ← Widget-level: dashboard card crash stays isolated
│ │ └── ChartErrorBoundary ← Chart isolated from table
Guiding principles:
- Independent functional modules should be isolated from each other. A failing chart should not take down a table on the same dashboard.
- Non-critical paths should be isolated. If a recommendation widget crashes, it should not break the main content area.
- Steps within a single user flow usually share one boundary. If a multi-step form crashes, the entire flow should fall back together.
Error Reporting with Sentry
npm install @sentry/react
// src/main.tsx
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
tracesSampleRate: 0.1, // sample 10% of performance traces
replaysOnErrorSampleRate: 1, // capture 100% of sessions with errors
});
// Sentry's ErrorBoundary wrapper automatically captures and reports errors
import { ErrorBoundary } from '@sentry/react';
function App() {
return (
<ErrorBoundary
fallback={<ErrorPage />}
showDialog // prompts user for feedback via Sentry's user feedback dialog
>
<Router />
</ErrorBoundary>
);
}
Manual reporting inside componentDidCatch:
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
Sentry.withScope((scope) => {
scope.setExtra('componentStack', errorInfo.componentStack);
scope.setTag('component', 'ErrorBoundary');
Sentry.captureException(error);
});
}
React 19 Error Handling Improvements
React 19 introduced significant improvements to the error handling infrastructure.
New Root Options: onCaughtError and onUncaughtError
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root')!, {
// Errors caught by an Error Boundary
onCaughtError(error, errorInfo) {
console.log('Caught by boundary:', error.message);
// errorInfo.componentStack contains the component stack trace
},
// Errors NOT caught by any Error Boundary (will unmount the tree)
onUncaughtError(error, errorInfo) {
console.error('Uncaught error, app will crash:', error.message);
Sentry.captureException(error);
},
// Recoverable errors (e.g., hydration mismatches React can automatically fix)
onRecoverableError(error, errorInfo) {
console.warn('Recoverable error:', error.message);
},
});
These callbacks are more precise than window.onerror or window.addEventListener('error') because they distinguish between errors that were caught by a boundary, uncaught errors that are fatal, and recoverable errors that React can heal automatically.
Clearer Hydration Error Messages
React 19 dramatically improved the error messages for SSR hydration mismatches. Previously, the message simply said "hydration mismatch." React 19 shows exactly what the server rendered versus what the client expected:
Hydration failed because the server rendered HTML didn't match the client.
Server: <div class="theme-dark">
Client: <div class="theme-light">
This makes debugging SSR applications—previously one of the most frustrating experiences in React—considerably more tractable.
Handling Async Errors
Error boundaries catch only errors thrown during the render phase. The following scenarios require separate handling.
Errors in Event Handlers
function DeleteButton({ onDelete }: { onDelete: () => Promise<void> }) {
const [error, setError] = useState<Error | null>(null);
const { showBoundary } = useErrorBoundary();
async function handleDelete() {
try {
await onDelete();
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
if (error.name === 'NetworkError') {
// Recoverable: show an inline message inside the component
setError(error);
} else {
// Non-recoverable: escalate to the nearest error boundary
showBoundary(error);
}
}
}
return (
<>
{error && <p role="alert" className="error">{error.message}</p>}
<button onClick={handleDelete}>Delete</button>
</>
);
}
Error Handling in Data Fetching
React Query provides a mature error handling model for asynchronous data:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data, error, isLoading, refetch } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: 3, // automatically retry up to 3 times on failure
retryDelay: 1000, // wait 1 second between each retry
});
if (isLoading) return <Skeleton />;
if (error) {
return (
<div role="alert">
<p>Failed to load user: {error.message}</p>
<button onClick={() => refetch()}>Retry</button>
</div>
);
}
return <UserCard user={data} />;
}
A complete error handling system is not a single technology choice. It is a layered defense: render errors handled by error boundaries, async errors handled by try/catch and state management, and global visibility handled by monitoring and reporting. Each layer has clear ownership. Together they allow an application to degrade gracefully under every failure mode—rather than leaving users staring at a white screen with no recourse.