During development, applications can encounter unexpected errors: from network issues, unexpected data, or even bugs in your code,… And for developers, it’s not enough to write functional code only, we must also handle these exceptions to prevent crashes, provide meaningful feedback to users, and maintain a seamless experience.
Imagine a user filling out a lengthy form and the entire application is crashed due to a minor issue in a deeply nested component. Without proper error handling:
- Debugging Nightmares: Without centralized error logging, identifying and fixing issues becomes incredibly difficult.
- Poor User Experience: Crashes and blank screens lead to unhappy user experience.
- Lost Data: Unhandled errors can interrupt user flows, potentially leading to lost input.
How tackle these challenges
1. Handling synchronous Errors
The most fundamental way to catch synchronous errors in JavaScript is with try...catch blocks. These are perfect for code that runs immediately and doesn’t involve rendering or component lifecycles in a direct way.
function calculateSum(a, b) {
try {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error("Inputs must be numbers.");
}
return a + b;
} catch (error) {
console.error("Error calculating sum:", error.message);
// You could return a default value, or re-throw, or notify the user
return null;
}
}
console.log(calculateSum(5, "abc")); // Output: Error calculating sum: Inputs must be numbers. \n null
2. Handling Asynchronous Errors
Most modern React applications interact with APIs, databases, or perform other operations that are asynchronous. These errors must be handled specifically.
With Promises (.then().catch())
function fetchData() {
fetch('https://api.example.com/data')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('Data fetched:', data);
})
.catch(error => {
console.error('Error fetching data:', error);
// Display error message to user, log it, etc.
});
}
useEffect(() => {
fetchData();
}, []);
With async/await (try...catch)
async function fetchDataAsync() {
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching data asynchronously:', error);
// Handle error state in your component
return null;
}
}
useEffect(() => {
fetchDataAsync();
}, []);
3. The React-Specific Solution: Error Boundaries
While try...catch is excellent for general JavaScript, but in React, it does NOT catch errors that occur during rendering phase. For those, you need something specifically designed for React. If a component throws an error during rendering, React needs a way to gracefully recover. This is where Error Boundaries come in.
It “catch” errors in:
- Their child component tree.
- During rendering, in lifecycle methods, hooks and in constructors of the tree below them.
It does not catch errors in:
- Event handlers.
- Asynchronous code.
- Error Boundary component itself (if the Error Boundary throws an error, it will propagate up).
- Server-side rendering errors.
An Error Boundary is a class component that implements either or both of the following lifecycle methods:
static getDerivedStateFromError(error): This method is called after an error has been thrown by a descendant component. It should return an object to update state, allowing you to render a fallback UI.componentDidCatch(error, errorInfo): This method is called after an error has been thrown by a descendant component. It’s used for side effects like logging the error information.
import React, { Component } from 'react';
class ErrorBoundary extends 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) {
// You can also log the error to an error reporting service
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({ error, errorInfo });
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<div>
<h2>Oops! Something went wrong.</h2>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
4. Global Error Handling (for Logging/Reporting)
For errors that slip through local try...catch blocks or are not caught by Error Boundaries (like unhandled promise rejections), you can set up global listeners, primarily for logging and reporting to services like Sentry, Bugsnag, or LogRocket.
window.onerror = function (message, source, lineno, colno, error) {
// Send to error reporting service
console.error("Global window.onerror caught:", { message, source, lineno, colno, error });
};
Best Practices for Error Handling
- Use Error Boundaries Strategically: Don’t wrap your entire app in one
ErrorBoundary. Place them around logical blocks or components that are prone to errors (e.g., data display components, complex widgets). This allows for partial UI degradation rather than a full app crash - User-Friendly Fallbacks: Instead of just a blank screen, show a friendly message, a loading spinner, or a specific error message
- Log Errors: Integrate with error monitoring services (e.g., Sentry, Datadog) to track and analyze errors in production.
- Don’t Hide Errors: never simply suppress errors without logging them. You need to know when things go wrong.
- Consider Data Loading States: Differentiate between an actual error and an empty data state. Users should know if there’s no data or if an error prevented data from loading.
