Introduction
Error boundaries are React's mechanism for handling JavaScript errors in component trees gracefully. Introduced in React 16, error boundaries catch errors during rendering, in lifecycle methods, and in constructors of the entire tree below them. Instead of crashing the entire application, error boundaries display a fallback UI and log the error for debugging.
Before error boundaries, a JavaScript error in any component would unmount the entire React component tree, leaving users with a blank screen. This was particularly problematic in production applications where a single rendering error in a non-critical component would take down the entire application.
Error boundaries solve this by providing component-level error isolation. A failing component is contained within its error boundary, while the rest of the application continues to function normally. This is similar to how try/catch works in JavaScript, but specifically designed for React's declarative component model.
This guide covers everything you need to know about error boundaries: from basic implementation to advanced recovery patterns, testing strategies, and production best practices.
Understanding Error Boundaries: Core Concepts
What Error Boundaries Catch
Error boundaries catch errors that occur during:
- Rendering: When a component's render method or function body throws
- Lifecycle methods:
componentDidMount,componentDidUpdate,getDerivedStateFromError - Constructors: Class component constructors
- Inside the error boundary tree: Any component below the boundary in the tree
// This error WILL be caught by an error boundary
function BuggyComponent() {
const data = undefined;
return <div>{data.map(item => <span key={item.id}>{item.name}</span>)}</div>;
// TypeError: Cannot read property 'map' of undefined
}What Error Boundaries Don't Catch
Error boundaries do NOT catch errors in:
- Event handlers: Errors in
onClick,onChange, etc. - Asynchronous code:
setTimeout,Promise,async/await - Server-side rendering: Errors during SSR
- The error boundary itself: Errors in the boundary's own render method
// This error will NOT be caught by an error boundary
function ComponentWithEventHandler() {
const handleClick = () => {
// This error happens outside the render cycle
throw new Error('Click handler error');
};
return <button onClick={handleClick}>Click me</button>;
}
// Handle these with regular try/catch
function ComponentWithAsyncError() {
useEffect(() => {
fetchData().catch(error => {
// Handle async errors manually
console.error('Async error:', error);
});
}, []);
}Error Boundary Placement
The placement of error boundaries in your component tree determines what gets isolated:
function App() {
return (
<div>
<Header />
<ErrorBoundary fallback={<SidebarError />}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary fallback={<ContentError />}>
<Content />
</ErrorBoundary>
<Footer />
</div>
);
}If Sidebar crashes, only the sidebar shows the error fallback. The header, content, and footer continue to work.
Architecture and Design Patterns
Class Component Error Boundary
The only way to create an error boundary is with a class component (as of React 18):
interface ErrorBoundaryProps {
fallback: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
onReset?: () => void;
resetKeys?: Array<unknown>;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log error to monitoring service
console.error('Error caught by boundary:', error, errorInfo);
this.props.onError?.(error, errorInfo);
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (this.state.hasError) {
// Reset when resetKeys change
if (this.props.resetKeys &&
this.props.resetKeys.some((key, i) => key !== prevProps.resetKeys?.[i])) {
this.reset();
}
}
}
reset() {
this.setState({ hasError: false, error: null });
this.props.onReset?.();
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}Hook-Based Error Boundary (Third-Party)
Libraries like react-error-boundary provide hook-based APIs:
import { ErrorBoundary, useErrorBoundary } from 'react-error-boundary';
function App() {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<ErrorFallback error={error} onRetry={resetErrorBoundary} />
)}
onReset={() => {
// Reset application state
}}
>
<MainContent />
</ErrorBoundary>
);
}
function ComponentWithError() {
const { showBoundary } = useErrorBoundary();
const handleAsyncOperation = async () => {
try {
await riskyOperation();
} catch (error) {
showBoundary(error);
}
};
}Recovery Patterns
Automatic Retry with Reset Keys:
function ErrorBoundaryWithRetry({ children, retryKey }) {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try Again</button>
</div>
)}
resetKeys={[retryKey]}
>
{children}
</ErrorBoundary>
);
}
// Usage
function App() {
const [retryKey, setRetryKey] = useState(0);
return (
<ErrorBoundaryWithRetry retryKey={retryKey}>
<UnstableComponent />
<button onClick={() => setRetryKey(k => k + 1)}>Reset Component</button>
</ErrorBoundaryWithRetry>
);
}Fallback with Navigation:
function PageErrorFallback({ error }) {
const navigate = useNavigate();
return (
<div>
<h1>Something went wrong</h1>
<p>{error.message}</p>
<button onClick={() => navigate('/')}>Go Home</button>
<button onClick={() => navigate(-1)}>Go Back</button>
</div>
);
}Step-by-Step Implementation
Step 1: Create a Reusable Error Boundary
// components/ErrorBoundary.tsx
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
onError?: (error: Error, info: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
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: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? <DefaultErrorFallback error={this.state.error!} />;
}
return this.props.children;
}
}
function DefaultErrorFallback({ error }: { error: Error }) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
</div>
);
}Step 2: Wrap Critical Sections
function App() {
return (
<ErrorBoundary>
<Header />
<ErrorBoundary fallback={<SidebarError />}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary fallback={<MainContentError />}>
<MainContent />
</ErrorBoundary>
</ErrorBoundary>
);
}Step 3: Add Error Logging
function logErrorToService(error: Error, errorInfo: React.ErrorInfo) {
// Send to error tracking service
fetch('/api/errors', {
method: 'POST',
body: JSON.stringify({
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
}),
});
}
<ErrorBoundary onError={logErrorToService}>
<App />
</ErrorBoundary>Step 4: Handle Async Errors
function useAsyncError() {
const [, setError] = useState();
return (error: Error) => {
setError(() => {
throw error;
});
};
}
function DataFetcher({ url }) {
const [data, setData] = useState(null);
const throwError = useAsyncError();
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(throwError);
}, [url]);
return data ? <Display data={data} /> : <Loading />;
}Step 5: Testing Error Boundaries
import { render, screen } from '@testing-library/react';
function ThrowingComponent({ shouldThrow }) {
if (shouldThrow) {
throw new Error('Test error');
}
return <div>Working</div>;
}
describe('ErrorBoundary', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('renders children when no error', () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<ThrowingComponent shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText('Working')).toBeInTheDocument();
});
it('renders fallback when error occurs', () => {
render(
<ErrorBoundary fallback={<div>Error occurred</div>}>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('Error occurred')).toBeInTheDocument();
});
it('calls onError callback', () => {
const onError = jest.fn();
render(
<ErrorBoundary fallback={<div>Error</div>} onError={onError}>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({ componentStack: expect.any(String) })
);
});
});Real-World Use Cases
Use Case 1: Micro-Frontend Isolation
function MicroFrontend({ name, url }) {
return (
<ErrorBoundary
fallback={<MicroFrontendError name={name} />}
onError={(error) => reportMicroFrontendError(name, error)}
>
<Suspense fallback={<Loading />}>
<RemoteComponent url={url} />
</Suspense>
</ErrorBoundary>
);
}
function App() {
return (
<div>
<Header />
<ErrorBoundary fallback={<WidgetError name="Dashboard" />}>
<DashboardWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="Analytics" />}>
<AnalyticsWidget />
</ErrorBoundary>
</div>
);
}Use Case 2: Form Error Recovery
function FormWithRecovery() {
const [formKey, setFormKey] = useState(0);
return (
<ErrorBoundary
key={formKey}
fallback={
<div>
<p>The form encountered an error</p>
<button onClick={() => setFormKey(k => k + 1)}>Reset Form</button>
</div>
}
>
<ComplexForm />
</ErrorBoundary>
);
}Use Case 3: Third-Party Widget Isolation
function ThirdPartyWidget({ config }) {
return (
<ErrorBoundary
fallback={
<div>
<p>Widget failed to load</p>
<button onClick={() => window.location.reload()}>Reload Page</button>
</div>
}
>
<ThirdPartyScript config={config} />
</ErrorBoundary>
);
}Best Practices for Production
-
Place boundaries strategically: Wrap sections that can fail independently without affecting the rest of the UI.
-
Provide meaningful fallbacks: Don't just show "Error occurred." Give users context and actions they can take.
-
Log errors to a service: Use
componentDidCatchto send errors to Sentry, LogRocket, or your own logging service. -
Use reset keys: Implement
resetKeysto automatically retry rendering when dependencies change. -
Don't overuse boundaries: Too many error boundaries create fragmented error states. One per major section is usually sufficient.
-
Test error states: Write tests that verify error boundaries catch errors and display fallbacks correctly.
-
Handle async errors separately: Error boundaries don't catch async errors. Use try/catch and
useErrorBoundaryfor those. -
Provide recovery actions: Give users buttons to retry, navigate home, or contact support.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Catching too broadly | Masks errors, hard to debug | Place boundaries at section level, not app root |
| Empty fallback UI | Users see blank screen | Always provide informative fallback with actions |
| Not logging errors | Lost debugging context | Use componentDidCatch to log to monitoring service |
| Expecting boundary to catch async errors | Errors still crash the app | Use try/catch for async, showBoundary for hooks |
| No recovery mechanism | Users stuck on error page | Implement reset buttons or automatic retries |
| Testing without suppressing console.error | Test output is noisy | Mock console.error in test setup |
Performance Optimization
Error boundaries have minimal performance impact:
- No overhead when no errors: The boundary's render method simply returns children
- State update on error: Only triggers when an error occurs
- Re-render scope: Only the boundary and its fallback re-render on error
// Optimized error boundary that doesn't re-render unnecessarily
class OptimizedErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
shouldComponentUpdate(nextProps: Props, nextState: State) {
// Only re-render if error state changed or children changed
return (
nextState.hasError !== this.state.hasError ||
nextProps.children !== this.props.children
);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}Comparison with Alternatives
| Feature | Error Boundary | try/catch | window.onerror | Error Pages |
|---|---|---|---|---|
| Scope | Component tree | Code block | Global | Route |
| UI Recovery | Fallback component | Manual | Manual | Full page |
| Granularity | Per-section | Per-block | Application | Per-route |
| Async Errors | No | Yes | Yes | Yes |
| React Integration | Deep | None | None | None |
| User Experience | Seamless | Depends | Depends | Full reload |
Advanced Patterns
Nested Error Boundaries with Fallback Chain
function App() {
return (
<ErrorBoundary fallback={<AppLevelError />}>
<Layout>
<ErrorBoundary fallback={<SectionLevelError />}>
<MainContent>
<ErrorBoundary fallback={<ComponentLevelError />}>
<UnstableComponent />
</ErrorBoundary>
</MainContent>
</ErrorBoundary>
</Layout>
</ErrorBoundary>
);
}Error Boundary with Suspense Integration
function DataBoundary({ children, fallback }) {
return (
<ErrorBoundary fallback={fallback}>
<Suspense fallback={<Loading />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
// Usage
<DataBoundary fallback={<DataError />}>
<AsyncDataComponent />
</DataBoundary>Global Error Handler Hook
function useGlobalErrorHandler() {
const { showBoundary } = useErrorBoundary();
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
showBoundary(event.reason);
};
const handleError = (event: ErrorEvent) => {
showBoundary(event.error);
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
window.addEventListener('error', handleError);
return () => {
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
window.removeEventListener('error', handleError);
};
}, [showBoundary]);
}Testing Strategies
Snapshot Testing Error Fallbacks
it('matches error fallback snapshot', () => {
const { container } = render(
<ErrorBoundary fallback={<ErrorFallback />}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(container).toMatchSnapshot();
});Integration Testing Error Recovery
it('recovers after reset', async () => {
const { rerender } = render(
<ErrorBoundary fallback={<div>Error</div>} resetKeys={[1]}>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('Error')).toBeInTheDocument();
rerender(
<ErrorBoundary fallback={<div>Error</div>} resetKeys={[2]}>
<ThrowingComponent shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.queryByText('Error')).not.toBeInTheDocument();
expect(screen.getByText('Working')).toBeInTheDocument();
});Error Boundaries with React 19 and Server Components
Error Handling in React Server Components
React Server Components introduce new error handling patterns that complement error boundaries:
// app/dashboard/page.tsx (Server Component)
// Use error.tsx for route-level error handling in Next.js App Router
export default async function DashboardPage() {
const data = await fetchDashboardData(); // If this throws, error.tsx catches it
return <Dashboard data={data} />;
}
// app/dashboard/error.tsx (Client Component)
'use client';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong loading the dashboard</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}Combining Error Boundaries with Suspense
Error boundaries work naturally with Suspense for loading and error states:
function UserProfile({ userId }: { userId: string }) {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Failed to load profile: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
)}
>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfileContent userId={userId} />
</Suspense>
</ErrorBoundary>
);
}Streaming Error Handling
For streaming SSR, errors may occur after the initial response has started:
// Server-side streaming with error handling
async function* StreamPage() {
try {
yield <Header />;
yield <Suspense fallback={<ContentSkeleton />}>
<AsyncContent />
</Suspense>;
yield <Footer />;
} catch (error) {
yield <ErrorBanner message="An error occurred while loading this page" />;
}
}Production Error Reporting Integration
Sentry Integration
Connect error boundaries to Sentry for production error tracking:
import * as Sentry from '@sentry/react';
function App() {
return (
<Sentry.ErrorBoundary
fallback={({ error, resetError }) => (
<ErrorFallback error={error} onRetry={resetError} />
)}
beforeCapture={(scope) => {
scope.setTag('errorBoundary', true);
scope.setLevel('error');
}}
onError={(error, componentStack) => {
// Custom error processing
console.error('Error boundary caught:', error);
}}
showDialog
>
<AppContent />
</Sentry.ErrorBoundary>
);
}
// Custom error boundary with Sentry
class SentryErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
Sentry.withScope((scope) => {
scope.setExtras(errorInfo);
scope.setTag('component', 'ErrorBoundary');
Sentry.captureException(error);
});
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}Custom Error Reporting Service
Build a custom error reporting pipeline:
interface ErrorReport {
message: string;
stack?: string;
componentStack?: string;
url: string;
userAgent: string;
timestamp: string;
userId?: string;
sessionId: string;
}
function reportError(error: Error, errorInfo: React.ErrorInfo) {
const report: ErrorReport = {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
userId: getCurrentUserId(),
sessionId: getSessionId(),
};
// Send to monitoring endpoint
fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(report),
}).catch(() => {
// Silently fail - don't error on error reporting
});
}
<ErrorBoundary
onError={(error, errorInfo) => reportError(error, errorInfo)}
fallback={<ErrorFallback />}
>
<AppContent />
</ErrorBoundary>Testing Error Boundaries
Testing with React Testing Library
import { render, screen, fireEvent } from '@testing-library/react';
// Suppress console.error for expected errors
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
afterAll(() => consoleSpy.mockRestore());
describe('ErrorBoundary', () => {
it('renders children when no error occurs', () => {
render(
<ErrorBoundary fallback={<div>Error occurred</div>}>
<div>Normal content</div>
</ErrorBoundary>
);
expect(screen.getByText('Normal content')).toBeInTheDocument();
});
it('renders fallback when child throws', () => {
const ThrowComponent = () => {
throw new Error('Test error');
};
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowComponent />
</ErrorBoundary>
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
it('calls onError callback when error occurs', () => {
const onError = jest.fn();
const ThrowComponent = () => {
throw new Error('Test error');
};
render(
<ErrorBoundary fallback={<div>Error</div>} onError={onError}>
<ThrowComponent />
</ErrorBoundary>
);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Test error' }),
expect.objectContaining({ componentStack: expect.any(String) })
);
});
it('recovers when resetKeys change', () => {
const { rerender } = render(
<ErrorBoundary fallback={<div>Error</div>} resetKeys={[1]}>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('Error')).toBeInTheDocument();
rerender(
<ErrorBoundary fallback={<div>Error</div>} resetKeys={[2]}>
<ThrowingComponent shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.queryByText('Error')).not.toBeInTheDocument();
});
});E2E Testing Error Scenarios
// Cypress E2E test
describe('Error handling', () => {
it('shows error fallback when API fails', () => {
// Intercept API call and force failure
cy.intercept('GET', '/api/users', { statusCode: 500 });
cy.visit('/users');
cy.get('[data-testid="error-boundary"]')
.should('be.visible')
.and('contain', 'Failed to load users');
// Test recovery
cy.intercept('GET', '/api/users', { fixture: 'users.json' });
cy.get('[data-testid="retry-button"]').click();
cy.get('[data-testid="user-list"]').should('be.visible');
});
});Future Outlook
- React Compiler integration: Automatic error boundary placement based on component analysis
- Server Component error handling: Better error boundaries for server-rendered content
- Improved async error handling: Native support for catching async errors in boundaries
- Error recovery patterns: Built-in support for common recovery strategies
Conclusion
Error boundaries are essential for building resilient React applications. They provide component-level error isolation that prevents a single failure from crashing your entire application.
Key takeaways:
- Error boundaries catch rendering errors, not event handler or async errors
- Place boundaries strategically at section boundaries that can fail independently
- Provide meaningful fallback UI with context and recovery actions
- Log errors to monitoring services for debugging in production
- Use reset keys for automatic recovery when dependencies change
- Handle async errors separately with try/catch and
useErrorBoundary - Test error states to verify boundaries work correctly
Error boundaries are your safety net in production. Implement them thoughtfully, and your users will never see a blank white screen again.