How do you handle errors in a React application?
Robust error handling is crucial for creating stable and user-friendly React applications. It allows you to gracefully manage unexpected issues, prevent crashes, and provide a better experience to your users by displaying meaningful fallback UIs or logging errors for debugging. React provides several mechanisms to handle errors, primarily through Error Boundaries, alongside standard JavaScript error handling techniques.
1. Error Boundaries
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 the component tree that crashed. They catch errors during rendering, in lifecycle methods, and in constructors of the whole tree below them. An Error Boundary is a class component that implements at least one of the lifecycle methods: static getDerivedStateFromError() or componentDidCatch().
static getDerivedStateFromError(error): This static method is invoked after an error has been thrown by a descendant component. It receives the error that was thrown as a parameter. It should return an object to update state, allowing the component to render a fallback UI.
componentDidCatch(error, errorInfo): This method is invoked after an error has been thrown by a descendant component. It receives two parameters: error (the error that was thrown) and errorInfo (an object with a componentStack key containing information about the component that threw the error). This method is used for side effects, such as logging the error information to an error tracking service.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// You can also log the error to an error reporting service
console.error("Uncaught error:", error, errorInfo);
// Example: logErrorToMyService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
To use an Error Boundary, you wrap a part of your component tree with it. If an error occurs within that wrapped section, the Error Boundary will catch it.
<ErrorBoundary>
<MyComponentThatMightCrash />
</ErrorBoundary>
Important Note: Error Boundaries do not catch errors for: event handlers, asynchronous code (e.g., setTimeout or requestAnimationFrame callbacks), server-side rendering, and errors thrown in the Error Boundary itself.
2. Try-Catch Blocks for Imperative Code
For errors that Error Boundaries cannot catch, such as those within event handlers or asynchronous code, standard JavaScript try-catch blocks are the appropriate solution. These are used for imperative logic.
function MyButton() {
const handleClick = () => {
try {
// Simulate an error in an event handler
throw new Error('An error occurred in handleClick!');
} catch (error) {
console.error('Caught error in handleClick:', error.message);
// You could update component state here to show an error message to the user
}
};
return <button onClick={handleClick}>Click Me</button>;
}
This also applies to custom hooks or utility functions that contain imperative logic that might throw an error.
3. Promise Error Handling
When dealing with asynchronous operations that return Promises (e.g., data fetching with fetch or axios), you should use .catch() for Promise chains or try-catch with async/await.
async function fetchData() {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error('Error fetching data:', error);
// Update state to show an error message or retry option
}
}
// Or with .then/.catch:
fetch('/api/data')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => console.log(data))
.catch(error => console.error('Error fetching data:', error));
4. Global Error Handlers (Browser APIs)
For truly uncaught errors that escape all other mechanisms, you can use global error handlers provided by the browser. These act as a last resort.
- window.onerror: Catches unhandled JavaScript errors that bubble up to the window.
- window.onunhandledrejection: Catches unhandled Promise rejections.
window.onerror = function(message, source, lineno, colno, error) {
console.error('Global unhandled error:', message, error);
// Log to a service, notify user, etc.
return true; // Prevent default browser error reporting
};
window.onunhandledrejection = function(event) {
console.error('Global unhandled promise rejection:', event.reason);
// Log to a service
event.preventDefault(); // Prevent default browser error reporting
};
It's important to set these up early in your application's lifecycle, typically in your main entry file (e.g., index.js).
5. Third-Party Error Reporting Services
For production applications, it's highly recommended to integrate with a dedicated error reporting and monitoring service. Tools like Sentry, Bugsnag, or Rollbar can automatically capture errors (including those caught by Error Boundaries and global handlers), provide context (user info, component stack, browser details), and help you triage and debug issues effectively.
Summary of Best Practices
- Use Error Boundaries for rendering errors in React component trees.
- Use try-catch blocks for imperative JavaScript code, especially in event handlers and
useEffectcallbacks. - Handle Promise rejections using
.catch()ortry-catchwithasync/awaitfor asynchronous operations. - Implement global
window.onerrorandwindow.onunhandledrejectionas a fallback for truly uncaught errors. - Integrate with a third-party error monitoring service for robust logging and reporting in production.