Axios and the API Layer — Centralised HTTP Client

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

🧠 Test Yourself

You have an Axios response interceptor that retries on 401. A request fails with 401. The interceptor refreshes the token and retries. The retry also gets a 401 (refresh token expired). What prevents an infinite loop?