1. Introduction
In every React application that communicates with external APIs, errors are inevitable.
Network failures, server outages, malformed responses, or unauthorized access can all cause your API calls to fail.
If not handled properly, these failures can crash your application or result in a poor user experience.
Error handling in API calls is therefore not just a technical task — it’s a user experience responsibility.
When your React app handles errors gracefully, users stay informed, the UI remains stable, and recovery options are offered seamlessly.
In this post, we’ll deeply explore how to manage API call failures using try/catch blocks, handle network and logical errors, display fallback UIs, and even retry failed requests intelligently.
2. Why Error Handling Matters in React Applications
Error handling ensures that your app stays reliable and user-friendly even when something goes wrong.
Without it, a single failed network request could break your component or show incomplete data to users.
Let’s consider some key reasons why error handling is critical:
- Improved user experience: Users should never face blank screens or crashes when data fails to load.
- Better debugging: Structured error logs make it easier for developers to diagnose issues.
- Application stability: Proper handling prevents React components from breaking the render cycle.
- Security: You can handle authentication or authorization errors gracefully instead of exposing backend details.
- Recovery options: You can offer users retry buttons or cached data when API calls fail.
3. Common Causes of API Call Errors
Understanding where errors come from helps in preventing and handling them.
API errors generally fall into four main categories:
a. Network Errors
These occur when the client can’t reach the server — for example, when the user’s internet is disconnected or the API endpoint is invalid.
b. Server Errors (5xx)
These indicate that the problem is on the backend — for instance, an internal server crash or unhandled exception.
c. Client Errors (4xx)
These happen when the request is invalid — such as unauthorized access (401), forbidden requests (403), or missing resources (404).
d. Parsing or Logical Errors
These happen when the data returned doesn’t match what your code expects — for instance, when the response format changes or contains invalid JSON.
4. Identifying and Handling Errors in Fetch API
The Fetch API doesn’t automatically throw errors for failed HTTP responses (like 404 or 500).
You must manually check if the response was successful.
Here’s a basic example:
async function fetchData() {
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();
console.log(data);
} catch (error) {
console.error('Fetch error:', error.message);
}
}
Explanation:
- The
response.okproperty checks if the status is in the range 200–299. - If it’s not, we manually throw an error.
- The
catchblock catches all network or custom-thrown errors.
This pattern ensures that you catch both network failures and HTTP errors.
5. Error Handling with Axios
Axios automatically throws an error for any non-2xx response, making error handling more convenient.
Example:
import axios from 'axios';
async function loadUsers() {
try {
const response = await axios.get('https://api.example.com/users');
console.log(response.data);
} catch (error) {
if (error.response) {
console.error('Server responded with error:', error.response.status);
} else if (error.request) {
console.error('No response received:', error.request);
} else {
console.error('Error in request setup:', error.message);
}
}
}
Key Points:
error.response: The server responded with an error status.error.request: The request was sent but no response received.error.message: Something went wrong while setting up the request.
This gives you detailed control over how to handle different types of failures.
6. Using try/catch Blocks in Asynchronous API Calls
The try/catch structure is the foundation of error handling in asynchronous JavaScript.
Example pattern:
try {
const response = await fetch(url);
const data = await response.json();
} catch (error) {
console.error('Error occurred:', error);
}
When combined with async/await, this pattern ensures that all runtime and network errors are caught and handled properly.
React Example:
import React, { useEffect, useState } from 'react';
function Products() {
const [products, setProducts] = useState([]);
const [error, setError] = useState('');
useEffect(() => {
async function getProducts() {
try {
const res = await fetch('https://api.example.com/products');
if (!res.ok) throw new Error('Failed to fetch products');
const data = await res.json();
setProducts(data);
} catch (err) {
setError(err.message);
}
}
getProducts();
}, []);
if (error) return <p>Error: {error}</p>;
if (!products.length) return <p>Loading...</p>;
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
export default Products;
Here, the component:
- Tries to fetch products.
- Catches any error and stores it in state.
- Displays a message instead of crashing.
7. Displaying Fallback UIs
A fallback UI keeps your application usable when an error occurs.
Instead of breaking, the app can display helpful feedback to users.
Example fallback interface:
if (error) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
Best Practices for Fallback UIs:
- Keep messages simple and human-readable.
- Avoid exposing technical details or API endpoints.
- Offer retry buttons or alternative actions.
- Use Error Boundaries for UI-level crashes.
8. Error Boundaries in React
React provides a powerful concept called Error Boundaries for handling errors that occur during rendering, lifecycle methods, or constructors of components.
They don’t catch errors inside event handlers or asynchronous code, but they’re essential for UI safety.
Example Error Boundary:
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, info) {
console.error('Error boundary caught:', error, info);
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong while loading the component.</h2>;
}
return this.props.children;
}
}
Usage:
<ErrorBoundary>
<DataFetchingComponent />
</ErrorBoundary>
Error Boundaries keep your application stable even if a component fails internally.
9. Creating Custom Error States
Instead of relying on generic messages, create custom error states for different failure scenarios.
Example:
if (error.includes('NetworkError')) {
return <p>Please check your internet connection.</p>;
}
if (error.includes('401')) {
return <p>Session expired. Please log in again.</p>;
}
if (error.includes('404')) {
return <p>Requested data not found.</p>;
}
Custom states allow you to deliver specific feedback based on the type of issue, improving UX and clarity.
10. Retrying Failed API Calls
Some API failures are temporary (like poor network).
You can implement automatic retries with delays or a “Retry” button.
Manual Retry Example:
function RetryableData() {
const [data, setData] = useState([]);
const [error, setError] = useState('');
async function fetchData() {
try {
setError('');
const res = await fetch('https://api.example.com/items');
if (!res.ok) throw new Error('Server error');
const result = await res.json();
setData(result);
} catch (err) {
setError(err.message);
}
}
useEffect(() => {
fetchData();
}, []);
if (error) {
return (
<div>
<p>Error: {error}</p>
<button onClick={fetchData}>Retry</button>
</div>
);
}
return <ul>{data.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}
Automatic Retry with Exponential Backoff:
async function fetchWithRetry(url, retries = 3, delay = 1000) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Request failed');
return await res.json();
} catch (err) {
if (retries === 0) throw err;
await new Promise(r => setTimeout(r, delay));
return fetchWithRetry(url, retries - 1, delay * 2);
}
}
This approach retries the API call after each failure, doubling the wait time each attempt.
11. Handling Specific HTTP Error Codes
Handling each error type differently improves usability.
Example:
switch (response.status) {
case 400:
throw new Error('Bad Request – Invalid input');
case 401:
throw new Error('Unauthorized – Please login again');
case 403:
throw new Error('Forbidden – Access denied');
case 404:
throw new Error('Not Found – Resource unavailable');
case 500:
throw new Error('Internal Server Error – Try later');
default:
throw new Error('Unexpected error');
}
This makes your error messages precise and helpful.
12. Global Error Handling with Axios Interceptors
Axios interceptors allow you to handle all errors in one centralized place.
Example:
import axios from 'axios';
axios.interceptors.response.use(
response => response,
error => {
if (error.response && error.response.status === 401) {
console.warn('Session expired, redirecting...');
}
return Promise.reject(error);
}
);
This keeps your components cleaner by managing common errors globally.
13. Logging and Monitoring API Errors
In production environments, silent failures are dangerous.
You should always log or monitor API errors for debugging.
Some approaches:
- Log errors to a remote logging service (e.g., Sentry or LogRocket).
- Store errors in state or a global context for diagnostics.
- Use
console.error()during development for visibility.
Example:
catch (error) {
console.error('API call failed:', error);
sendErrorToServer(error);
}
Proper monitoring ensures quick issue resolution and better system reliability.
14. Graceful Fallbacks with Cached or Default Data
If the API fails, you can still display cached or placeholder data instead of breaking the UI.
Example:
if (error) {
return (
<div>
<p>Couldn’t load live data. Showing saved version:</p>
<ul>{cachedData.map(i => <li key={i.id}>{i.name}</li>)}</ul>
</div>
);
}
This keeps your app functional even without network connectivity.
15. Error Handling in React Query and SWR
If you use data-fetching libraries like React Query or SWR, they have built-in error handling mechanisms.
React Query Example:
import { useQuery } from '@tanstack/react-query';
function Users() {
const { data, error, isLoading } = useQuery('users', async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to load users');
return res.json();
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div>{data.map(u => <p key={u.id}>{u.name}</p>)}</div>;
}
React Query automatically handles loading and error states.
16. Best Practices for Error Handling
- Always wrap API calls in try/catch blocks.
- Use
response.okchecks for Fetch API. - Provide user-friendly error messages.
- Log detailed errors for developers.
- Use fallback UIs instead of blank screens.
- Centralize error logic with interceptors or custom hooks.
- Retry intelligently for temporary failures.
- Avoid exposing backend messages directly to users.
Following these practices ensures stability, security, and usability.
17. Complete Example – Resilient API Component
import React, { useState, useEffect } from 'react';
function SafeUserList() {
const [users, setUsers] = useState([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
async function fetchUsers() {
try {
setLoading(true);
setError('');
const res = await fetch('https://jsonplaceholder.typicode.com/users');
if (!res.ok) throw new Error(Server error: ${res.status});
const data = await res.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchUsers();
}, []);
if (loading) return <p>Loading users...</p>;
if (error)
return (
<div>
<p>Error: {error}</p>
<button onClick={fetchUsers}>Retry</button>
</div>
);
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
export default SafeUserList;
This complete example demonstrates error detection, loading state, and retry logic — everything a production-ready component should have.
18. Testing Your Error Handling
You can simulate API failures to test your logic:
- Change the API URL to an invalid one.
- Turn off your internet connection.
- Mock error responses using tools like Mock Service Worker (MSW) or Jest mocks.
Testing ensures that your fallback UIs and retry systems behave correctly under real-world conditions.
19. Handling Authentication Errors
Authentication errors (401 or 403) require special care.
You can redirect users to a login page when a token expires:
if (response.status === 401) {
navigate('/login');
}
This keeps unauthorized users from accessing restricted content gracefully.
Leave a Reply