Scattered fetch("/api/...") calls throughout components create maintenance headaches: the base URL is duplicated everywhere, authentication headers must be added to every request, error handling is repeated in every component. An Axios instance solves this by centralising HTTP configuration — one place to set the base URL, automatically attach auth tokens via request interceptors, handle 401 errors and token refresh via response interceptors, and standardise error handling. Every component’s API call becomes a clean, thin function call.
Installing and Configuring Axios
npm install axios
// src/services/api.js — the centralised Axios instance
import axios from "axios";
const api = axios.create({
baseURL: "/api", // Vite proxy handles /api → http://localhost:8000/api
timeout: 10_000, // 10 second timeout
headers: {
"Content-Type": "application/json",
},
});
// ── Request interceptor — attach auth token to every request ─────────────────
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// ── Response interceptor — handle 401 (token refresh) and errors ──────────────
api.interceptors.response.use(
(response) => response, // 2xx: pass through
async (error) => {
const originalRequest = error.config;
// Auto-refresh: 401 + not already retried
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem("refresh_token");
const { data } = await axios.post("/api/auth/refresh", { refresh_token: refreshToken });
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
originalRequest.headers.Authorization = `Bearer ${data.access_token}`;
return api(originalRequest); // retry the original request
} catch {
// Refresh failed — log out
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
return Promise.reject(error);
}
}
return Promise.reject(error);
}
);
export default api;
Note: The
_retry flag on the original request config prevents infinite retry loops. Without it, if the refresh token is also expired, the 401 from the refresh call would trigger another refresh attempt, which would fail again, creating an infinite loop of failed refresh requests until the browser tab crashes. The _retry = true flag marks the request as “already retried once” — if it fails again, the error propagates normally.Tip: Storing tokens in
localStorage is convenient but vulnerable to XSS attacks — any JavaScript on the page can read localStorage. For high-security applications, store tokens in HttpOnly cookies (the server sets them; JavaScript cannot read them). The trade-off is more complex token management on the backend. For a learning project or internal application, localStorage is acceptable. For a public-facing production application with sensitive data, consider HttpOnly cookies.Warning: Axios returns response data in
response.data (the parsed response body), not directly. This is different from fetch which returns the raw Response object and requires res.json(). Axios also automatically throws for non-2xx status codes (no need to check res.ok). The error in the catch block has a response property for HTTP errors: error.response.status, error.response.data.API Service Modules
// src/services/posts.js
import api from "./api";
export const postsApi = {
list: (params = {}) =>
api.get("/posts", { params }).then((r) => r.data),
getById: (id) =>
api.get(`/posts/${id}`).then((r) => r.data),
getBySlug: (slug) =>
api.get(`/posts/by-slug/${slug}`).then((r) => r.data),
create: (postData) =>
api.post("/posts", postData).then((r) => r.data),
update: (id, changes) =>
api.patch(`/posts/${id}`, changes).then((r) => r.data),
delete: (id) =>
api.delete(`/posts/${id}`),
like: (id) =>
api.post(`/posts/${id}/like`).then((r) => r.data),
};
// src/services/auth.js
import api from "./api";
export const authApi = {
login: (email, password) =>
api.post("/auth/login", { email, password }).then((r) => r.data),
register: (data) =>
api.post("/auth/register", data).then((r) => r.data),
refresh: (refreshToken) =>
api.post("/auth/refresh", { refresh_token: refreshToken }).then((r) => r.data),
logout: (refreshToken) =>
api.post("/auth/logout", { refresh_token: refreshToken }),
me: () =>
api.get("/users/me").then((r) => r.data),
};
Using the API Layer in Components
import { useState, useEffect } from "react";
import { postsApi } from "@/services/posts";
function PostFeed({ page, tag }) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
setError(null);
postsApi.list({ page, tag, page_size: 10 })
.then((data) => { setData(data); setIsLoading(false); })
.catch((err) => {
if (err.name === "CanceledError") return; // Axios uses CanceledError
setError(err.response?.data?.detail ?? err.message);
setIsLoading(false);
});
return () => controller.abort();
}, [page, tag]);
// ...
}
Common Mistakes
Mistake 1 — Accessing response data without .data (Axios)
❌ Wrong — response is the Axios response object, not the data:
const posts = await api.get("/posts");
console.log(posts.items); // undefined — posts is the response, not the body!
✅ Correct:
const response = await api.get("/posts");
const posts = response.data; // ✓ or: const { data: posts } = await api.get(...)
Mistake 2 — AbortError name is different in Axios
❌ Wrong — checking for “AbortError” with Axios:
catch (err) {
if (err.name === "AbortError") return; // Axios throws "CanceledError"!
}
✅ Correct:
catch (err) {
if (axios.isCancel(err)) return; // ✓ or: err.name === "CanceledError"
Quick Reference
| Task | Code |
|---|---|
| Create instance | axios.create({ baseURL, timeout, headers }) |
| GET request | api.get("/path", { params }).then(r => r.data) |
| POST request | api.post("/path", body).then(r => r.data) |
| Request interceptor | api.interceptors.request.use(config => { ...; return config }) |
| Response interceptor | api.interceptors.response.use(res => res, err => ...) |
| Auth header | config.headers.Authorization = "Bearer " + token |
| Error status | error.response.status, error.response.data |