Fetch API and Axios: Error Handling and Patterns
React has a superpower: it lets you build interactive UIs that respond to real data. But real data means real networks — and real networks fail. Orders time out. APIs return 404s. Servers crash. Payment services go down at the worst moment.
If you've followed along with our earlier posts on useEffect with dependencies and cleanup and server vs client state management, you already know how React manages asynchronous operations and state. Now it's time to go deeper — into the mechanics of how you fetch data, and more importantly, how you handle when things go wrong.
This article covers the two most popular approaches — the Fetch API (built into the browser) and Axios (a popular library) — and walks through practical error handling patterns using an Order Management System (OMS) context. Every code snippet here is complete and runnable so you can paste it into your local project.
Setting Up the Project
Before diving in, let's make sure you have a React project ready. If you've already got one, skip ahead. If not, run:
npx create-react-app oms-fetch-demo
cd oms-fetch-demo
npm install axios
npm startWe'll organize our project like this:
src/
api/
fetchOrders.js ← Fetch API utilities
axiosClient.js ← Axios instance setup
components/
OrderList.js ← Uses Fetch API
OrderDetails.js ← Uses Axios
ErrorBoundary.js ← React error boundary
App.jsPart 1: The Fetch API — Simple, Native, Tricky
The Fetch API is baked right into the browser. No installation needed. It uses Promises and has a clean syntax.
Here's the beginner version of fetching orders:
src/api/fetchOrders.js
export async function fetchOrders() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=5");
const data = await response.json();
return data;
}Looks clean, right? But this code has a serious problem — it doesn't handle errors properly.
The Fetch Trap: HTTP Errors Are NOT Exceptions
Here's the most important thing beginners get wrong about fetch:
Fetch only throws an error for network failures (no internet, DNS failure, etc.). It does NOT throw for HTTP errors like 404, 500, or 403.
If the server returns a 500 (Internal Server Error), fetch will happily move on and give you a response object with response.ok === false. Your app will silently fail — no error thrown, no warning, just broken data.
The correct pattern:
src/api/fetchOrders.js
export async function fetchOrders(status = "all") {
const url = `https://jsonplaceholder.typicode.com/todos?_limit=8`;
const response = await fetch(url);
// CRITICAL: manually check if the response was successful
if (!response.ok) {
throw new Error(`Failed to fetch orders. Status: ${response.status}`);
}
const data = await response.json();
return data;
}Now let's build a component that uses this properly.
OrderList Component — Fetch API with Error Handling
File: src/components/OrderList.js
import React, { useState, useEffect } from "react";
import { fetchOrders } from "../api/fetchOrders";
function OrderList() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function loadOrders() {
try {
setLoading(true);
setError(null);
const data = await fetchOrders();
if (!cancelled) {
// Map JSONPlaceholder data to look like OMS orders
const mapped = data.map((item) => ({
id: item.id,
title: `Order #${1000 + item.id}`,
status: item.completed ? "Delivered" : "Processing",
}));
setOrders(mapped);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadOrders();
return () => {
cancelled = true;
};
}, []);
if (loading) return <p style={styles.info}>Loading orders...</p>;
if (error) return <p style={styles.error}>⚠️ Error: {error}</p>;
return (
<div style={styles.container}>
<h2>📦 Order List (Fetch API)</h2>
<ul style={styles.list}>
{orders.map((order) => (
<li key={order.id} style={styles.item}>
<strong>{order.title}</strong> — {order.status}
</li>
))}
</ul>
</div>
);
}
const styles = {
container: { padding: "1rem", fontFamily: "sans-serif" },
list: { listStyle: "none", padding: 0 },
item: {
padding: "0.5rem",
marginBottom: "0.5rem",
background: "#f0f4ff",
borderRadius: "6px",
},
error: { color: "red", fontWeight: "bold" },
info: { color: "#555" },
};
export default OrderList;What to notice here:
cancelledflag prevents state updates after the component unmounts (this avoids the classic "Can't perform a React state update on an unmounted component" warning — see our useEffect cleanup article for the full explanation).try/catch/finallyhandles both errors and always resetsloading.erroris stored in state and shown to the user instead of silently failing.
Part 2: Axios — Smarter HTTP for Serious Apps
Axios is a third-party library that wraps fetch (or XMLHttpRequest) with a much friendlier interface. It has several advantages for OMS-style apps:
- Automatically throws on HTTP errors (4xx, 5xx) — no manual
response.okcheck needed - Automatic JSON parsing — no
response.json()call - Built-in request/response interceptors — perfect for auth tokens, logging
- Request cancellation — great for search-as-you-type
- Better timeout support
Setting Up a Reusable Axios Instance
Rather than using axios directly everywhere, you should create a centralized instance with default configuration. This is especially important in an OMS where every request likely needs an auth token or base URL.
File: src/api/axiosClient.js
import axios from "axios";
const axiosClient = axios.create({
baseURL: "https://jsonplaceholder.typicode.com",
timeout: 8000, // fail the request if it takes more than 8 seconds
headers: {
"Content-Type": "application/json",
// In a real OMS, you'd add: Authorization: `Bearer ${token}`
},
});
// REQUEST INTERCEPTOR
// Runs before every request — great for attaching auth tokens
axiosClient.interceptors.request.use(
(config) => {
// You can dynamically attach a token here
// const token = localStorage.getItem("authToken");
// if (token) config.headers.Authorization = `Bearer ${token}`;
console.log(`[OMS Request] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
return Promise.reject(error);
}
);
// RESPONSE INTERCEPTOR
// Runs after every response — great for global error handling
axiosClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response) {
// Server responded with a non-2xx status
const { status } = error.response;
if (status === 401) {
console.error("[OMS] Unauthorized — please log in again.");
// In a real app: redirect to login page
} else if (status === 403) {
console.error("[OMS] Forbidden — you don't have access.");
} else if (status === 404) {
console.error("[OMS] Resource not found.");
} else if (status >= 500) {
console.error("[OMS] Server error — try again later.");
}
} else if (error.request) {
// Request was made but no response received
console.error("[OMS] No response received — check your network.");
} else {
// Something else triggered the error
console.error("[OMS] Request setup error:", error.message);
}
return Promise.reject(error);
}
);
export default axiosClient;This is a pattern you'll see in almost every production React app. Think of it as your OMS's communication gateway — every outgoing and incoming message passes through here.
OrderDetails Component — Axios with Error Handling
File: src/components/OrderDetails.js
import React, { useState, useEffect } from "react";
import axiosClient from "../api/axiosClient";
function OrderDetails({ orderId }) {
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadOrder() {
try {
setLoading(true);
setError(null);
const response = await axiosClient.get(`/todos/${orderId}`, {
signal: controller.signal,
});
const item = response.data;
setOrder({
id: item.id,
title: `Order #${1000 + item.id}`,
status: item.completed ? "Delivered" : "In Progress",
customerId: item.userId,
});
} catch (err) {
if (err.name === "CanceledError" || err.name === "AbortError") {
// Request was cancelled — don't show error
return;
}
if (err.response) {
setError(`Server Error: ${err.response.status} — ${err.response.statusText}`);
} else if (err.request) {
setError("Network error — please check your internet connection.");
} else {
setError(`Unexpected error: ${err.message}`);
}
} finally {
setLoading(false);
}
}
loadOrder();
return () => {
controller.abort(); // cancel the request on unmount
};
}, [orderId]);
if (loading) return <p style={styles.info}>Loading order #{orderId}...</p>;
if (error) return <p style={styles.error}>⚠️ {error}</p>;
if (!order) return null;
return (
<div style={styles.card}>
<h3>{order.title}</h3>
<p>Status: <strong>{order.status}</strong></p>
<p>Customer ID: {order.customerId}</p>
</div>
);
}
const styles = {
card: {
padding: "1rem",
background: "#e8f5e9",
borderRadius: "8px",
fontFamily: "sans-serif",
marginTop: "1rem",
},
error: { color: "red", fontWeight: "bold" },
info: { color: "#555" },
};
export default OrderDetails;The three-way Axios error check is important:
| Condition | Meaning | OMS Example |
|---|---|---|
err.response exists | Server replied with an error code | Order not found (404), Unauthorized (401) |
err.request exists | Request was made, no response came back | Network timeout, server down |
| Neither | Error before request was even sent | Bad URL, config mistake |
If you've read our article on props vs state, you'll recognize that order, loading, and error are all local state — they belong inside this component because they only affect what this component displays.
Part 3: Retry Logic — When the First Try Fails
In an OMS, a momentary blip — a slow warehouse API, a brief network hiccup — shouldn't ruin the user's experience. A smart retry with a small delay can save a lot of frustration.
File: src/api/fetchWithRetry.js
export async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return await response.json();
} catch (err) {
if (attempt === retries) {
throw new Error(`All ${retries} attempts failed. Last error: ${err.message}`);
}
console.warn(`[OMS] Attempt ${attempt} failed. Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}File: src/components/RetryOrderFetch.js
import React, { useState } from "react";
import { fetchWithRetry } from "../api/fetchWithRetry";
function RetryOrderFetch() {
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function handleFetch() {
setLoading(true);
setError(null);
setResult(null);
try {
// Using a working URL — swap to a bad URL to see retries in action
const data = await fetchWithRetry(
"https://jsonplaceholder.typicode.com/todos/1",
{},
3,
800
);
setResult({
title: `Order #${1000 + data.id}`,
status: data.completed ? "Delivered" : "Processing",
});
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div style={styles.container}>
<h2>🔄 Retry Pattern Demo</h2>
<button onClick={handleFetch} style={styles.button} disabled={loading}>
{loading ? "Fetching..." : "Fetch Order with Retry"}
</button>
{error && <p style={styles.error}>⚠️ {error}</p>}
{result && (
<div style={styles.card}>
<p><strong>{result.title}</strong></p>
<p>Status: {result.status}</p>
</div>
)}
</div>
);
}
const styles = {
container: { padding: "1rem", fontFamily: "sans-serif" },
button: {
padding: "0.6rem 1.2rem",
background: "#1a73e8",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontSize: "1rem",
},
error: { color: "red", fontWeight: "bold" },
card: {
padding: "0.8rem",
background: "#fff3e0",
borderRadius: "6px",
marginTop: "0.5rem",
},
};
export default RetryOrderFetch;To test the retry in action, temporarily swap the URL to something broken like https://jsonplaceholder.typicode.com/bad-endpoint and watch the console log retries.
Part 4: ErrorBoundary — Catching Unexpected Crashes
Even with good try/catch in your hooks, some errors can escape to the component tree level. React's ErrorBoundary is a class component pattern that catches these.
File: src/components/ErrorBoundary.js
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, message: "" };
}
static getDerivedStateFromError(error) {
return { hasError: true, message: error.message };
}
componentDidCatch(error, info) {
console.error("[OMS ErrorBoundary] Caught error:", error, info);
}
render() {
if (this.state.hasError) {
return (
<div style={styles.box}>
<h3>🚨 Something went wrong in the OMS</h3>
<p>{this.state.message}</p>
<button
style={styles.button}
onClick={() => this.setState({ hasError: false, message: "" })}
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
const styles = {
box: {
padding: "1.5rem",
background: "#fff3f3",
border: "2px solid #ff4444",
borderRadius: "8px",
fontFamily: "sans-serif",
},
button: {
marginTop: "0.5rem",
padding: "0.5rem 1rem",
background: "#ff4444",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
},
};
export default ErrorBoundary;Bringing It All Together: App.js
File: src/App.js
import React, { useState } from "react";
import OrderList from "./components/OrderList";
import OrderDetails from "./components/OrderDetails";
import RetryOrderFetch from "./components/RetryOrderFetch";
import ErrorBoundary from "./components/ErrorBoundary";
function App() {
const [selectedOrderId, setSelectedOrderId] = useState(1);
return (
<div style={styles.app}>
<h1 style={styles.header}>🏪 OMS — Fetch & Axios Error Handling Demo</h1>
<section style={styles.section}>
<ErrorBoundary>
<OrderList />
</ErrorBoundary>
</section>
<section style={styles.section}>
<h2>🔍 Order Details (Axios)</h2>
<div style={styles.controls}>
<label>Select Order ID: </label>
<input
type="number"
min={1}
max={20}
value={selectedOrderId}
onChange={(e) => setSelectedOrderId(Number(e.target.value))}
style={styles.input}
/>
</div>
<ErrorBoundary>
<OrderDetails orderId={selectedOrderId} />
</ErrorBoundary>
</section>
<section style={styles.section}>
<RetryOrderFetch />
</section>
</div>
);
}
const styles = {
app: {
maxWidth: "800px",
margin: "0 auto",
padding: "2rem",
fontFamily: "sans-serif",
},
header: {
color: "#1a237e",
borderBottom: "3px solid #1a73e8",
paddingBottom: "0.5rem",
},
section: {
marginBottom: "2rem",
padding: "1rem",
background: "#fafafa",
borderRadius: "10px",
boxShadow: "0 2px 8px rgba(0,0,0,0.07)",
},
controls: {
marginBottom: "1rem",
display: "flex",
alignItems: "center",
gap: "0.8rem",
},
input: {
padding: "0.4rem",
fontSize: "1rem",
width: "80px",
borderRadius: "4px",
border: "1px solid #ccc",
},
};
export default App;Quick Comparison: Fetch vs Axios
| Feature | Fetch API | Axios |
|---|---|---|
| Built into browser | ✅ Yes | ❌ Requires install |
| Auto JSON parse | ❌ Manual .json() call | ✅ Automatic |
| Throws on HTTP errors | ❌ Must check response.ok | ✅ Automatic |
| Interceptors | ❌ Need manual wrappers | ✅ Built-in |
| Request cancellation | ✅ AbortController | ✅ AbortController |
| Timeout support | ⚠️ Manual via AbortController | ✅ timeout option |
| Bundle size | 0 KB (native) | ~13 KB |
For small apps or when bundle size matters, Fetch is perfectly fine. For enterprise OMS applications with auth tokens, global error handling, and retry patterns, Axios is the better choice.
Patterns Summary
Here's a quick mental model for what we covered:
Always check response.ok with Fetch — HTTP errors are silent by default.
Use a centralized Axios instance — configure it once, use it everywhere. Think of it like the OMS's central communication hub.
Handle three error types with Axios — err.response (server error), err.request (no response), and fallback (setup error).
Use AbortController for cleanup — if you've read our useEffect cleanup article, you know why cancelling async work on unmount is critical.
Wrap components in ErrorBoundary — it's your safety net for anything that slips through.
Retry with a delay — for flaky network conditions in warehouse or logistics environments, a simple retry loop can dramatically improve reliability.
What's Next?
We've now got solid patterns for fetching data manually. But in real-world OMS apps, you'll often need caching, background refetching, deduplication, and loading states that work across components — all without writing boilerplate useEffect code.
That's exactly what the next article covers:
📖 Next Article: SWR & React Query for fast, cached data fetching
These two libraries take everything we've learned here and supercharge it — giving you intelligent caching, automatic revalidation, and a dramatically cleaner developer experience. Stay tuned!
Member discussion