π‘ API Requests with Axios
Every interactive app eventually needs to talk to a server: load a list, submit a form, refresh a dashboard. Axios is the go-to HTTP client that makes those calls concise and consistent. This lesson takes you from a first GET request to production-grade patterns β a shared instance, interceptors, cancellation, and a reusable data-fetching hook.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Make GET, POST, PUT, and DELETE requests with Axios using
async/await - Explain the practical differences between Axios and the Fetch API
- Create a reusable Axios instance with a base URL and default headers
- Use request and response interceptors to attach tokens and handle errors globally
- Cancel in-flight requests with AbortController to prevent race conditions and leaks
- Package fetching logic into a reusable custom React hook
Estimated Time: 45β60 minutes β’ Difficulty: Intermediate
Hands-on: Build a debounced search box that cancels stale requests as the user types.
In This Lesson
Why Axios?
Axios is a promise-based HTTP client that runs in both the browser and Node.js. It has stayed popular for years because it trims away the repetitive boilerplate that raw HTTP calls demand: it parses JSON for you, treats HTTP error statuses as real errors, supports request timeouts and cancellation out of the box, and lets you register interceptors that run on every request or response.
π‘ Analogy: If sending an HTTP request were like mailing a package, the built-in fetch is the basic postal service β it gets there, but you handle the paperwork. Axios is the premium courier: tracking, insurance, automatic customs forms (JSON parsing), and a single desk (interceptors) that stamps every parcel the same way.
Install it into your React project:
npm install axios
Axios vs. the Fetch API
fetch is built into every modern browser and is perfectly capable. Axios isn't "better" so much as more convenient for app-scale code. The two differences that bite most often are JSON handling and error handling.
| Concern | Axios | Fetch API |
|---|---|---|
| JSON parsing | Automatic β res.data is ready | Manual β call await res.json() |
| HTTP error (4xx/5xx) | Rejects the promise β hits catch | Resolves normally β you must check res.ok |
| Timeouts | Built-in timeout option | Manual with AbortController |
| Interceptors | Built-in | Not available β wrap it yourself |
| Bundle size | Extra dependency | Zero β already in the browser |
See the error-handling gap concretely. With fetch, a 404 or 500 does not throw:
// fetch: a failing status still "succeeds" β you must check res.ok yourself
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
const data = await res.json();
// axios: a failing status rejects, landing in catch automatically
try {
const { data } = await axios.get('/api/users');
} catch (err) {
console.error(err.response?.status); // e.g. 404
}
π‘ When to reach for which
For a tiny script or a single call, fetch is fine and adds nothing to your bundle. For a full app with auth tokens, consistent error handling, and many endpoints, Axios's interceptors and defaults earn their keep. This lesson uses Axios; the concepts transfer directly if you later standardize on fetch.
The Four Basic Requests
The four verbs you'll use constantly map to reading and changing data. Each returns a promise resolving to a response object whose data property holds the parsed body.
import axios from 'axios';
const BASE = 'https://api.example.com';
// GET β read a collection
async function getUsers() {
const { data } = await axios.get(`${BASE}/users`);
return data;
}
// POST β create a new record
async function createUser(user) {
const { data } = await axios.post(`${BASE}/users`, user);
return data;
}
// PUT β replace/update an existing record
async function updateUser(id, user) {
const { data } = await axios.put(`${BASE}/users/${id}`, user);
return data;
}
// DELETE β remove a record
async function deleteUser(id) {
await axios.delete(`${BASE}/users/${id}`);
}
π The response object
Every successful Axios call resolves to an object with these fields:
{
data, // the parsed response body β what you usually want
status, // HTTP status code, e.g. 200
statusText, // e.g. 'OK'
headers, // response headers
config // the request configuration that produced this response
}
Because of this, you'll destructure const { data } = await axios.get(...) almost every time.
Handling errors precisely
When a request fails, Axios gives you an error object with three distinct shapes. Checking them tells you where things broke:
try {
const { data } = await axios.get('/api/data');
} catch (err) {
if (err.response) {
// Server responded with a non-2xx status
console.error('Status:', err.response.status);
console.error('Body:', err.response.data);
} else if (err.request) {
// Request sent, but no response (server down, network offline)
console.error('No response received');
} else {
// Something went wrong building the request
console.error('Setup error:', err.message);
}
}
A Reusable Axios Instance
Calling axios.get('https://api.example.com/...') and repeating the base URL everywhere is fragile. Instead, create one configured instance and import it wherever you need the API. This is the single most important habit for keeping API code maintainable.
// src/services/api.js
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api', // Vite env var
timeout: 10000, // 10s safety net
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
}
});
export default api;
β οΈ Vite reads env vars differently than CRA
In a Vite project, environment variables are exposed on import.meta.env and must be prefixed with VITE_ (e.g. VITE_API_URL) to be visible in the browser bundle. The old Create React App convention of process.env.REACT_APP_* does not apply here.
Now build thin, readable service modules on top of the instance:
// src/services/userService.js
import api from './api';
export const userService = {
getAll: () => api.get('/users').then((r) => r.data),
getById: (id) => api.get(`/users/${id}`).then((r) => r.data),
create: (user) => api.post('/users', user).then((r) => r.data),
update: (id, user) => api.put(`/users/${id}`, user).then((r) => r.data),
remove: (id) => api.delete(`/users/${id}`).then((r) => r.data)
};
β What you gain
One place to set the base URL, one place to add auth, easy to mock in tests, and components that read like plain function calls (userService.getAll()) instead of scattered HTTP details.
Interceptors
Interceptors are hooks that run automatically on every request before it's sent, or every response before your then/catch sees it. They're perfect for cross-cutting concerns you'd otherwise repeat in every call β most commonly, attaching an auth token and handling expired sessions.
Request interceptor β attach the token
// src/services/api.js (continued)
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config; // must return the config
},
(error) => Promise.reject(error)
);
Response interceptor β handle expired sessions
api.interceptors.response.use(
(response) => response, // pass successful responses through
(error) => {
if (error.response?.status === 401) {
// Token invalid or expired β force a fresh login
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error); // let the caller still see the error
}
);
π‘ Why this beats copy-paste
Without interceptors you'd add the Authorization header and 401 check to every service function. With them, one file governs auth for the whole app. Change your token scheme once, and every request follows.
Cancelling Requests
Cancellation matters more than beginners expect. Imagine a search box firing a request per keystroke: responses can arrive out of order, so a slow response for "re" might land after "react", overwriting the newer results with stale ones. Cancelling the previous request prevents this race β and prevents React from trying to update an unmounted component.
The modern, standard approach is the browser's AbortController (Axios's old CancelToken is deprecated):
const controller = new AbortController();
api.get('/users/search', {
params: { q: 'react' },
signal: controller.signal // link the request to the controller
});
// Later β cancel it
controller.abort();
Inside a React effect, this pattern belongs in the cleanup function so a new search (or an unmount) cancels the previous request:
import { useState, useEffect } from 'react';
import axios from 'axios';
import api from '../services/api';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
api
.get('/search', { params: { q: query }, signal: controller.signal })
.then((res) => setResults(res.data))
.catch((err) => {
// Ignore the error that cancellation itself throws
if (!axios.isCancel(err) && err.name !== 'CanceledError') {
console.error(err);
}
});
// Cleanup: cancel the in-flight request on the next run or unmount
return () => controller.abort();
}, [query]);
return (
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
);
}
β οΈ Cancellation looks like an error
When you abort a request, the promise rejects with a cancellation error. Always guard your catch with axios.isCancel(err) (or check err.name === 'CanceledError') so you don't show the user a scary "request failed" message for something you did on purpose.
A Custom useFetch Hook
The loading/error/data trio shows up in every component that fetches. Extract it into a custom hook once and reuse it everywhere β cancellation included.
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
import axios from 'axios';
import api from '../services/api';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
api
.get(url, { signal: controller.signal })
.then((res) => setData(res.data))
.catch((err) => {
if (!axios.isCancel(err) && err.name !== 'CanceledError') {
setError(err.response?.data?.message || err.message);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
Components become dramatically shorter β they describe the UI, not the plumbing:
// src/components/UserList.jsx
import { useFetch } from '../hooks/useFetch';
export default function UserList() {
const { data: users, loading, error } = useFetch('/users');
if (loading) return <p>Loading usersβ¦</p>;
if (error) return <p role="alert">Error: {error}</p>;
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name} ({u.email})</li>
))}
</ul>
);
}
π‘ Beyond hand-rolled hooks
For real apps, libraries like TanStack Query (React Query) or SWR build on exactly these ideas and add caching, background refetching, and deduplication. Writing useFetch yourself first makes those libraries feel obvious rather than magical.
Hands-on: Debounced Search
ποΈ Build a live search that behaves
Objective: Create a search input that queries an API as the user types, debounces to avoid a request per keystroke, and cancels stale requests so results never arrive out of order.
Instructions:
- Create a
Searchcomponent with a controlled input bound to aquerystate. - In a
useEffectkeyed onquery, start asetTimeoutof ~400ms before firing the request (debounce). - Create an
AbortControllerand pass itssignalto the Axios call. - In the cleanup function, clear the timeout and abort the controller.
- Ignore cancellation errors; show real errors to the user.
π‘ Hint
The cleanup function runs before the next effect and on unmount β that's exactly when you want to both clearTimeout and controller.abort(). Debouncing (the timeout) reduces how many requests you start; cancellation (the abort) discards ones already in flight.
β Sample solution
// src/components/Search.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import api from '../services/api';
export default function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
setError('');
try {
const res = await api.get('/search', {
params: { q: query },
signal: controller.signal
});
setResults(res.data);
} catch (err) {
if (!axios.isCancel(err) && err.name !== 'CanceledError') {
setError('Search failed');
}
} finally {
setLoading(false);
}
}, 400);
return () => {
clearTimeout(timer); // cancel the pending debounce
controller.abort(); // cancel the in-flight request
};
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Searchβ¦"
/>
{loading && <p>Searchingβ¦</p>}
{error && <p role="alert">{error}</p>}
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</div>
);
}
Type quickly and only the final query's results appear β no flicker, no stale overwrite.
π― Quick Quiz
Question 1: How does Axios differ from fetch when the server returns a 500 status?
Question 2: What is the main purpose of a request interceptor?
Question 3: Why cancel a previous request in a search-as-you-type box?
Summary & Quiz
π Key Takeaways
- Axios makes HTTP calls concise: it parses JSON automatically and rejects on error statuses, unlike
fetch. - Destructure
const { data } = await axios.get(...)β the body lives onresponse.data. - Create one configured Axios instance and build thin service modules on top of it.
- Interceptors centralize cross-cutting logic like attaching tokens and handling 401s.
- Cancel stale requests with AbortController and guard
catchwithaxios.isCancel. - A custom useFetch hook reuses the loading/error/data pattern; libraries like TanStack Query extend it.
π Further Reading
- Axios documentation
- MDN β AbortController
- TanStack Query (React Query)
- React β useEffect and cleanup
π What's Next?
You can now move data cleanly between React and a server. Next we combine everything into a complete feature: Authentication Flow in the MERN Stack β JWTs, protected routes, token storage, and refresh.
π Great work!
Interceptors and cancellation are the patterns that separate toy fetch calls from production data layers.