Error Boundaries
Error Boundaries in React
Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of crashing the entire application.
Basic Error Boundary
Simple Error Boundary
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// Log the error to an error reporting service
console.error('Error caught by boundary:', error, errorInfo);
this.setState({
error,
errorInfo
});
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h2>Something went wrong</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo.componentStack}
</details>
</div>
);
}
return this.props.children;
}
}
// Usage
function App() {
return (
<ErrorBoundary>
<Header />
<MainContent />
<Footer />
</ErrorBoundary>
);
}
Advanced Error Boundary
Enhanced Error Boundary with Features
class AdvancedErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
eventId: null
};
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
const { onError, enableLogging = true } = this.props;
// Log to console in development
if (process.env.NODE_ENV === 'development') {
console.group('🚨 Error Boundary Caught An Error');
console.error('Error:', error);
console.error('Error Info:', errorInfo);
console.error('Component Stack:', errorInfo.componentStack);
console.groupEnd();
}
// Log to external service
if (enableLogging) {
this.logErrorToService(error, errorInfo);
}
// Call custom error handler
if (onError) {
onError(error, errorInfo);
}
this.setState({
error,
errorInfo
});
}
logErrorToService = (error, errorInfo) => {
// Example with Sentry
if (window.Sentry) {
window.Sentry.withScope((scope) => {
scope.setTag('errorBoundary', true);
scope.setContext('errorInfo', errorInfo);
const eventId = window.Sentry.captureException(error);
this.setState({ eventId });
});
}
// Example with custom logging service
fetch('/api/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: error.toString(),
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href
})
}).catch(err => {
console.error('Failed to log error:', err);
});
};
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
eventId: null
});
};
render() {
const { hasError, error, errorInfo, eventId } = this.state;
const { fallback, children, showDetails = false } = this.props;
if (hasError) {
// Custom fallback component
if (fallback) {
return fallback(error, errorInfo, this.handleReset);
}
return (
<div className="error-boundary">
<div className="error-content">
<h1>🚨 Oops! Something went wrong</h1>
<p>We're sorry, but something unexpected happened.</p>
<div className="error-actions">
<button onClick={this.handleReset} className="retry-button">
Try Again
</button>
<button onClick={() => window.location.reload()}>
Reload Page
</button>
</div>
{eventId && (
<p className="error-id">
Error ID: <code>{eventId}</code>
</p>
)}
{showDetails && process.env.NODE_ENV === 'development' && (
<details className="error-details">
<summary>Error Details (Development Only)</summary>
<pre>
<strong>Error:</strong> {error.toString()}
{'\n\n'}
<strong>Component Stack:</strong>
{errorInfo.componentStack}
{'\n\n'}
<strong>Error Stack:</strong>
{error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return children;
}
}
// Usage with custom fallback
function CustomErrorFallback(error, errorInfo, retry) {
return (
<div className="custom-error">
<h2>Something went wrong in this section</h2>
<p>Don't worry, the rest of the app is still working!</p>
<button onClick={retry}>Try Again</button>
</div>
);
}
function App() {
return (
<AdvancedErrorBoundary
fallback={CustomErrorFallback}
onError={(error, errorInfo) => {
console.log('Custom error handler:', error);
}}
showDetails={true}
>
<Dashboard />
</AdvancedErrorBoundary>
);
}
Granular Error Boundaries
Component-Level Error Boundaries
// Wrap individual components
function Dashboard() {
return (
<div className="dashboard">
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary fallback={MainContentErrorFallback}>
<MainContent />
</ErrorBoundary>
<ErrorBoundary fallback={WidgetErrorFallback}>
<Widgets />
</ErrorBoundary>
</div>
);
}
function SidebarErrorFallback() {
return (
<div className="sidebar-error">
<p>Sidebar couldn't load</p>
<button onClick={() => window.location.reload()}>
Reload
</button>
</div>
);
}
function MainContentErrorFallback() {
return (
<div className="main-content-error">
<h2>Content Error</h2>
<p>The main content failed to load.</p>
</div>
);
}
Route-Level Error Boundaries
import { Routes, Route } from 'react-router-dom';
function App() {
return (
<Routes>
<Route path="/" element={
<ErrorBoundary fallback={HomeErrorFallback}>
<Home />
</ErrorBoundary>
} />
<Route path="/profile" element={
<ErrorBoundary fallback={ProfileErrorFallback}>
<Profile />
</ErrorBoundary>
} />
<Route path="/dashboard/*" element={
<ErrorBoundary fallback={DashboardErrorFallback}>
<Dashboard />
</ErrorBoundary>
} />
</Routes>
);
}
Async Error Handling
Handling Promise Rejections
class AsyncErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('AsyncErrorBoundary caught an error:', error, errorInfo);
}
componentDidMount() {
// Listen for unhandled promise rejections
window.addEventListener('unhandledrejection', this.handlePromiseRejection);
}
componentWillUnmount() {
window.removeEventListener('unhandledrejection', this.handlePromiseRejection);
}
handlePromiseRejection = (event) => {
console.error('Unhandled promise rejection:', event.reason);
// Optionally treat promise rejections as errors
if (this.props.catchAsyncErrors) {
this.setState({
hasError: true,
error: new Error(`Async Error: ${event.reason}`)
});
// Prevent the default browser behavior
event.preventDefault();
}
};
render() {
if (this.state.hasError) {
return (
<div>
<h2>Async Error Occurred</h2>
<p>{this.state.error.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Retry
</button>
</div>
);
}
return this.props.children;
}
}
// Component that might have async errors
function AsyncComponent() {
const [data, setData] = useState(null);
useEffect(() => {
// This promise rejection would be caught by AsyncErrorBoundary
fetch('/api/data')
.then(response => {
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
})
.then(setData)
.catch(error => {
// If you want the error boundary to catch this,
// you need to throw in a component lifecycle
throw error;
});
}, []);
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}
Error Boundary Hook (React 18+)
Using Error Boundaries with Hooks
// Custom hook to throw errors that error boundaries can catch
function useErrorHandler() {
const [, setError] = useState();
return useCallback((error) => {
setError(() => {
throw error;
});
}, []);
}
// Functional component that can trigger error boundary
function FunctionalComponentWithErrors() {
const throwError = useErrorHandler();
const handleAsyncError = async () => {
try {
await riskyAsyncOperation();
} catch (error) {
throwError(error); // This will be caught by error boundary
}
};
return (
<div>
<button onClick={() => throwError(new Error('Manual error'))}>
Throw Error
</button>
<button onClick={handleAsyncError}>
Async Error
</button>
</div>
);
}
// React 18 alternative: use ErrorBoundary from react-error-boundary library
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<h2>Something went wrong:</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, errorInfo) => {
console.log('Error:', error);
console.log('Error Info:', errorInfo);
}}
onReset={() => {
// Reset app state
window.location.reload();
}}
>
<MyApp />
</ErrorBoundary>
);
}
Error Boundary Context
Providing Error Context
const ErrorContext = createContext();
class ErrorBoundaryProvider extends React.Component {
constructor(props) {
super(props);
this.state = {
errors: []
};
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
const errorData = {
id: Date.now(),
error,
errorInfo,
timestamp: new Date().toISOString()
};
this.setState(prevState => ({
errors: [...prevState.errors, errorData]
}));
}
clearError = (errorId) => {
this.setState(prevState => ({
errors: prevState.errors.filter(err => err.id !== errorId),
hasError: prevState.errors.length <= 1 ? false : prevState.hasError
}));
};
clearAllErrors = () => {
this.setState({ errors: [], hasError: false });
};
render() {
const contextValue = {
errors: this.state.errors,
clearError: this.clearError,
clearAllErrors: this.clearAllErrors
};
return (
<ErrorContext.Provider value={contextValue}>
{this.state.hasError ? (
<ErrorDisplay errors={this.state.errors} onClear={this.clearError} />
) : (
this.props.children
)}
</ErrorContext.Provider>
);
}
}
function ErrorDisplay({ errors, onClear }) {
return (
<div className="error-display">
<h2>Errors Occurred ({errors.length})</h2>
{errors.map(({ id, error, timestamp }) => (
<div key={id} className="error-item">
<p><strong>Error:</strong> {error.message}</p>
<p><small>Time: {new Date(timestamp).toLocaleString()}</small></p>
<button onClick={() => onClear(id)}>Dismiss</button>
</div>
))}
</div>
);
}
// Hook to use error context
function useErrors() {
const context = useContext(ErrorContext);
if (!context) {
throw new Error('useErrors must be used within ErrorBoundaryProvider');
}
return context;
}
Testing Error Boundaries
Unit Testing Error Boundaries
import { render, screen } from '@testing-library/react';
import ErrorBoundary from './ErrorBoundary';
// Component that throws an error
function ThrowError({ shouldThrow }) {
if (shouldThrow) {
throw new Error('Test error');
}
return <div>No error</div>;
}
describe('ErrorBoundary', () => {
// Suppress console.error for cleaner test output
const originalError = console.error;
beforeAll(() => {
console.error = jest.fn();
});
afterAll(() => {
console.error = originalError;
});
test('renders children when there is no error', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText('No error')).toBeInTheDocument();
});
test('renders error message when child component throws', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
test('calls onError callback when error occurs', () => {
const onError = jest.fn();
render(
<ErrorBoundary onError={onError}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(onError).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
componentStack: expect.any(String)
})
);
});
});
Error Boundary Patterns
Retry Mechanism
class RetryErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
retryCount: 0,
error: null
};
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
const { maxRetries = 3, onError } = this.props;
if (onError) {
onError(error, errorInfo, this.state.retryCount);
}
// Auto-retry if under limit
if (this.state.retryCount < maxRetries) {
setTimeout(() => {
this.setState(prevState => ({
hasError: false,
retryCount: prevState.retryCount + 1,
error: null
}));
}, 1000 * Math.pow(2, this.state.retryCount)); // Exponential backoff
}
}
handleManualRetry = () => {
this.setState({
hasError: false,
retryCount: 0,
error: null
});
};
render() {
const { hasError, retryCount, error } = this.state;
const { maxRetries = 3, children } = this.props;
if (hasError) {
if (retryCount >= maxRetries) {
return (
<div className="retry-error-boundary">
<h2>Something went wrong</h2>
<p>We tried {maxRetries} times but couldn't recover.</p>
<p>Error: {error?.message}</p>
<button onClick={this.handleManualRetry}>
Try Again Manually
</button>
</div>
);
}
return (
<div className="retry-error-boundary">
<p>Something went wrong. Retrying... (Attempt {retryCount + 1}/{maxRetries})</p>
</div>
);
}
return children;
}
}
Best Practices
-
Place Error Boundaries Strategically
- Don't wrap every component
- Place at route and feature boundaries
- Consider component importance
-
Provide Meaningful Fallbacks
- Show user-friendly error messages
- Provide recovery options
- Maintain app functionality where possible
-
Log Errors Appropriately
- Send to error monitoring services
- Include relevant context
- Respect user privacy
-
Test Error Scenarios
- Test error boundary rendering
- Test error logging
- Test recovery mechanisms
-
Handle Async Errors
- Error boundaries don't catch async errors
- Use try-catch for promises
- Consider global error handlers
Error boundaries are essential for building resilient React applications that gracefully handle unexpected errors!