The Problem: Unhandled Render Errors
When a JavaScript error occurs during rendering, React used to corrupt its internal state and produce broken UI on the next render. There was no way to recover โ the entire component tree would unmount, leaving a blank white screen.
Before Error Boundaries:
User interaction --> Component throws during render
|
v
Entire app crashes
White screen of death
No recovery possible
Error boundaries solve this. They catch errors in their child component tree and display a fallback UI instead of crashing the entire application.
With Error Boundaries:
User interaction --> Component throws during render
|
v
Error boundary catches it
Fallback UI displayed
Rest of the app works fine
What Error Boundaries Catch
Error boundaries catch errors that occur during:
- Rendering (in the return statement or render method)
- Lifecycle methods (componentDidMount, componentDidUpdate, etc.)
- Constructors of child components
What Error Boundaries Do NOT Catch
Error boundaries specifically do not catch errors in:
- Event handlers โ use try/catch inside the handler
- Asynchronous code โ setTimeout, requestAnimationFrame, fetch callbacks
- Server-side rendering โ error boundaries are client-side only
- Errors in the boundary itself โ a boundary cannot catch its own errors
+---------------------------------------------------+
| Error Boundary CATCHES: |
| - Render errors in children |
| - Lifecycle errors in children |
| - Constructor errors in children |
+---------------------------------------------------+
+---------------------------------------------------+
| Error Boundary DOES NOT CATCH: |
| - Event handler errors (use try/catch) |
| - Async errors (setTimeout) (use try/catch) |
| - Server-side rendering (use try/catch) |
| - Errors in the boundary (use parent) |
+---------------------------------------------------+
Building an Error Boundary
Error boundaries must be class components. This is one of the few cases where class components are still required โ React does not (as of React 18) offer a hook equivalent for componentDidCatch or getDerivedStateFromError.
Minimal Implementation
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state to show fallback UI on next render
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log the error for debugging
console.error('Error caught by boundary:', error);
console.error('Component stack:', errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
Two static/lifecycle methods power the boundary:
-
getDerivedStateFromError(error)โ called during the render phase, returns new state. This is where you toggle the fallback UI. Must be a pure function (no side effects). -
componentDidCatch(error, errorInfo)โ called during the commit phase. This is where you log errors, send to monitoring services, etc. Side effects are allowed here.
Usage
function App() {
return (
<div>
<Header />
<ErrorBoundary>
<MainContent />
</ErrorBoundary>
<Footer />
</div>
);
}
If MainContent (or any of its children) throws during rendering, the error boundary shows the fallback instead. Header and Footer continue working normally.
Customizable Error Boundary
A production error boundary should accept a custom fallback and expose the error to it.
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
resetError = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
// If a fallback component is provided, render it with error info
if (this.props.fallback) {
const FallbackComponent = this.props.fallback;
return (
<FallbackComponent
error={this.state.error}
resetError={this.resetError}
/>
);
}
// Default fallback
return (
<div role="alert" style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Something went wrong</h2>
<button onClick={this.resetError}>Try again</button>
</div>
);
}
return this.props.children;
}
}
Custom Fallback Components
function ChartErrorFallback({ error, resetError }) {
return (
<div className="chart-error">
<p>Failed to load chart data.</p>
<p className="error-detail">{error.message}</p>
<button onClick={resetError}>Reload Chart</button>
</div>
);
}
function WidgetErrorFallback({ error, resetError }) {
return (
<div className="widget-error">
<p>This widget encountered an error.</p>
<button onClick={resetError}>Retry</button>
</div>
);
}
// Usage with different fallbacks for different sections
function Dashboard() {
return (
<div className="dashboard">
<ErrorBoundary fallback={ChartErrorFallback}>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={WidgetErrorFallback}>
<ActiveUsersWidget />
</ErrorBoundary>
<ErrorBoundary fallback={WidgetErrorFallback}>
<RecentOrdersWidget />
</ErrorBoundary>
</div>
);
}
Error Recovery Strategies
Strategy 1: Retry Rendering
The simplest recovery โ reset the error state and let React try to render the children again. Works when the error was caused by a transient condition (race condition, intermittent data issue).
function RetryFallback({ error, resetError }) {
return (
<div role="alert">
<p>Something went wrong: {error.message}</p>
<button onClick={resetError}>Try Again</button>
</div>
);
}
Strategy 2: Retry with Data Refetch
When the error came from bad data, reset the boundary and trigger a fresh data fetch.
function DataSection({ endpoint }) {
const [fetchKey, setFetchKey] = useState(0);
const boundaryRef = useRef(null);
const handleRetry = () => {
setFetchKey(prev => prev + 1); // forces child to refetch
};
return (
<ErrorBoundary
fallback={({ resetError }) => (
<div>
<p>Failed to load data.</p>
<button onClick={() => { resetError(); handleRetry(); }}>
Refetch Data
</button>
</div>
)}
>
<DataDisplay key={fetchKey} endpoint={endpoint} />
</ErrorBoundary>
);
}
Strategy 3: Degraded Mode
Show a simpler version of the component that is unlikely to fail.
function MapSection() {
return (
<ErrorBoundary
fallback={({ error }) => (
<div className="map-fallback">
<p>Interactive map unavailable.</p>
<p>Error: {error.message}</p>
<a href="https://maps.google.com" target="_blank" rel="noopener">
Open in Google Maps
</a>
</div>
)}
>
<InteractiveMap />
</ErrorBoundary>
);
}
Strategy 4: Reset on Navigation
Reset the boundary when the user navigates to a different page or section.
import { useLocation } from 'react-router-dom';
function RouteErrorBoundary({ children }) {
const location = useLocation();
return (
<ErrorBoundary key={location.pathname}>
{children}
</ErrorBoundary>
);
}
Using location.pathname as the key causes the error boundary to unmount and remount on every route change, clearing any error state automatically.
Error Logging
In production, console.error is not enough. Send errors to a monitoring service.
class ErrorBoundary extends Component {
// ... state and getDerivedStateFromError same as before
componentDidCatch(error, errorInfo) {
// Log to monitoring service
logErrorToService({
error: {
message: error.message,
stack: error.stack,
name: error.name,
},
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent,
});
}
render() {
// ... same as before
}
}
// Example logging function
async function logErrorToService(errorData) {
try {
await fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(errorData),
});
} catch {
// Logging should never break the app
console.error('Failed to log error to service');
}
}
The componentStack from errorInfo is extremely valuable โ it shows the exact component hierarchy that led to the error:
The above error occurred in the <UserProfile> component:
at UserProfile (src/components/UserProfile.jsx:15)
at div
at Dashboard (src/pages/Dashboard.jsx:8)
at ErrorBoundary (src/components/ErrorBoundary.jsx:5)
at App (src/App.jsx:12)
Integration with Monitoring Services
// Sentry integration
import * as Sentry from '@sentry/react';
class ErrorBoundary extends Component {
componentDidCatch(error, errorInfo) {
Sentry.withScope(scope => {
scope.setExtra('componentStack', errorInfo.componentStack);
Sentry.captureException(error);
});
}
// ...
}
// Or use Sentry's built-in boundary
const SentryBoundary = Sentry.withErrorBoundary(MyComponent, {
fallback: <p>Something went wrong</p>,
});
Boundaries at Multiple Levels
A single top-level boundary catches everything, but shows a generic fallback for any error. Multiple boundaries at different levels provide granular control.
+--[ App ]------------------------------------------+
| +--[ ErrorBoundary: Page Level ]---------------+ |
| | +--[ Header ]-----------------------------+ | |
| | | +--[ ErrorBoundary: Widget Level ]----+ | | |
| | | | +--[ SearchWidget ] | | | | |
| | | | | Error here -> widget fallback | | | | |
| | | | +----------------------------------+ | | | |
| | | +-------------------------------------+ | | |
| | +------------------------------------------+ | |
| | +--[ Main ]-------------------------------+ | |
| | | +--[ ErrorBoundary: Section Level ]---+ | | |
| | | | +--[ UserProfile ] | | | | |
| | | | | Error here -> section fallback | | | | |
| | | | +---------------------------------+ | | | |
| | | +------------------------------------+ | | |
| | | +--[ ErrorBoundary: Section Level ]---+ | | |
| | | | +--[ ActivityFeed ] | | | | |
| | | | | Error here -> section fallback| | | | |
| | | | +--------------------------------+ | | | |
| | | +------------------------------------+ | | |
| | +------------------------------------------+ | |
| +-----------------------------------------------+ |
+-----------------------------------------------------+
Practical Multi-level Setup
function App() {
return (
// Top-level: catastrophic failures only
<ErrorBoundary fallback={FullPageError}>
<Layout>
{/* Page-level: catches routing errors */}
<ErrorBoundary fallback={PageError}>
<Routes>
<Route path="/dashboard" element={
<DashboardPage />
} />
</Routes>
</ErrorBoundary>
</Layout>
</ErrorBoundary>
);
}
function DashboardPage() {
return (
<div className="grid">
{/* Widget-level: each widget fails independently */}
<ErrorBoundary fallback={WidgetError}>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={WidgetError}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary fallback={WidgetError}>
<RecentActivity />
</ErrorBoundary>
</div>
);
}
If RevenueChart throws, only that widget shows the error fallback. The rest of the dashboard, the navigation, the footer โ all continue working.
Handling Errors in Event Handlers
Since error boundaries do not catch event handler errors, use try/catch:
function DeleteButton({ itemId, onDelete }) {
const [error, setError] = useState(null);
const handleClick = async () => {
try {
setError(null);
await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
onDelete(itemId);
} catch (err) {
setError(err.message);
}
};
return (
<div>
<button onClick={handleClick}>Delete</button>
{error && <p className="error">Failed: {error}</p>}
</div>
);
}
For a centralized approach, create a hook:
function useErrorHandler() {
const [error, setError] = useState(null);
// Throw the error during render so the nearest boundary catches it
if (error) throw error;
const handleError = (err) => {
setError(err);
};
return handleError;
}
function DataComponent() {
const handleError = useErrorHandler();
const loadData = async () => {
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
} catch (err) {
handleError(err); // This will be caught by the nearest error boundary
}
};
return <button onClick={loadData}>Load Data</button>;
}
This hook bridges the gap between async errors and error boundaries. It stores the error in state, and on the next render, throws it โ which the boundary catches.
User-Friendly Error UI
Error fallbacks should help users, not confuse them.
Good Error Fallback Checklist
- Clear, non-technical message
- Recovery action (retry button, navigation link)
- Visual consistency with the rest of the app
- Accessible (role="alert", proper focus management)
- Does not expose stack traces or implementation details
function PageErrorFallback({ error, resetError }) {
return (
<div role="alert" className="error-page">
<div className="error-content">
<h1>We hit a snag</h1>
<p>
This page could not load properly. This has been reported
and we are looking into it.
</p>
<div className="error-actions">
<button onClick={resetError} className="btn-primary">
Try Again
</button>
<a href="/" className="btn-secondary">
Go Home
</a>
</div>
</div>
</div>
);
}
Development vs Production Fallback
function ErrorFallback({ error, resetError }) {
const isDev = process.env.NODE_ENV === 'development';
return (
<div role="alert" className="error-fallback">
<h2>Something went wrong</h2>
<button onClick={resetError}>Try again</button>
{isDev && (
<details className="error-details">
<summary>Error details (dev only)</summary>
<pre>{error.message}</pre>
<pre>{error.stack}</pre>
</details>
)}
</div>
);
}
Using react-error-boundary Library
The react-error-boundary package provides a well-tested, feature-rich error boundary as a function-component-friendly API.
npm install react-error-boundary
import { ErrorBoundary, useErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => logErrorToService(error, info)}
onReset={() => {
// Reset app state here if needed
}}
>
<MainApp />
</ErrorBoundary>
);
}
// Use inside components to programmatically show the boundary
function DataLoader() {
const { showBoundary } = useErrorBoundary();
const loadData = async () => {
try {
await fetchSomeData();
} catch (err) {
showBoundary(err); // triggers the nearest error boundary
}
};
return <button onClick={loadData}>Load</button>;
}
Testing Error Boundaries
import { render, screen } from '@testing-library/react';
// Suppress console.error during error boundary tests
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (/React will try to recreate/.test(args[0])) return;
originalError.call(console, ...args);
};
});
afterAll(() => { console.error = originalError; });
function BrokenComponent() {
throw new Error('Test error');
}
test('error boundary shows fallback on error', () => {
render(
<ErrorBoundary fallback={({ error }) => (
<div role="alert">Error: {error.message}</div>
)}>
<BrokenComponent />
</ErrorBoundary>
);
expect(screen.getByRole('alert')).toHaveTextContent('Error: Test error');
});
test('error boundary recovers on retry', () => {
let shouldThrow = true;
function MaybeBreaks() {
if (shouldThrow) throw new Error('Broken');
return <p>Working</p>;
}
render(
<ErrorBoundary fallback={({ resetError }) => (
<div>
<p role="alert">Something broke</p>
<button onClick={() => { shouldThrow = false; resetError(); }}>
Retry
</button>
</div>
)}>
<MaybeBreaks />
</ErrorBoundary>
);
expect(screen.getByRole('alert')).toBeInTheDocument();
fireEvent.click(screen.getByText('Retry'));
expect(screen.getByText('Working')).toBeInTheDocument();
});
Key Takeaways
- Error boundaries prevent a single component error from crashing the entire app
- They catch render, lifecycle, and constructor errors โ not event handlers or async code
- Error boundaries must be class components (no hook equivalent yet)
- Use multiple boundaries at different levels for granular error isolation
- Always provide a user-friendly fallback with recovery options
- Log errors to a monitoring service in
componentDidCatch - Use the key prop to reset boundaries on navigation
- Bridge async errors to boundaries with a state-and-throw pattern
- The
react-error-boundarylibrary provides a convenient API for common patterns - Test error boundaries by rendering deliberately broken components