Back to Blog

How to Set Up Axios with Token Refresh, Interceptors, and Global Error Handling in React

Production-ready API client with auth and refresh

12 min read
  • React
  • Axios
  • Authentication
  • TypeScript

Axios is a powerful HTTP client that makes it easy to interact with APIs in React applications. While basic usage is straightforward, configuring Axios properly — with interceptors, token-based authentication, and automatic token refreshing — can save you from future headaches.

In this guide, we’ll walk through how to structure Axios in a React application to handle:

  • Centralized API configuration
  • Authentication with access and refresh tokens
  • Automatic token refreshing
  • Global error handling

Why Use Axios Instead of Others?

  • Built-in JSON parsing
  • Interceptors for requests and responses
  • Automatic cancellation
  • Timeout support
  • Clean syntax
  • Node.js support

In complex applications, these features become essential.

Folder Structure

src/
├── api/
│   ├── axios.config.ts    # Axios instance with base config
│   ├── axios.request.ts   # Request interceptors
│   ├── axios.response.ts  # Response interceptors
│   └── axios.helper.ts    # Token refresh logic
└── store/
    └── useAuthStore.ts    # Auth token store (Zustand/Redux/Mobx etc)

1. Create Axios Instance

// src/api/axios.config.ts
import axios from "axios";
import request from "./axios.request";
import response from "./axios.response";

const axiosInstance = axios.create({
  baseURL: API_BASE_URL || "http://localhost:8080/api",
  headers: {
    "Content-Type": "application/json",
    Accept: "application/json",
  },
});

// Attach interceptors
axiosInstance.interceptors.request.use(
  request.onRequest,
  request.onRequestError,
);
axiosInstance.interceptors.response.use(
  response.onResponse,
  response.onResponseError,
);

export default axiosInstance;

What it does:

  • Creates a centralized axiosInstance with a baseURL, content headers, and attached request and response interceptors.
  • Makes your app easier to maintain by keeping configuration in one place.

Why it matters:

  • You won’t need to repeat headers or base URL for every request.
  • All requests automatically run through the interceptors.

2. Set Up Request Interceptor (Inject Access Token)

// src/api/axios.request.ts
import { useAuthStore } from "@/store/useAuthStore";
import { isPublicRoute } from "@/utils/helper"; // Optional helper
import type { AxiosError } from "axios";

const onRequest = (config: any) => {
  const { accessToken, refreshToken } = useAuthStore.getState();

  // Skip attaching token for public routes
  if (isPublicRoute(config.url as string, config.method as string))
    return config;

  if (!accessToken && !refreshToken) {
    throw new Error("No access or refresh token found");
  }

  config.headers = {
    ...config.headers,
    Authorization: `Bearer ${accessToken}`,
  };

  return config;
};

const onRequestError = (error: AxiosError) => {
  return Promise.reject(error);
};

const request = { onRequest, onRequestError };
export default request;

What it does:

  • Automatically adds the Authorization header to outgoing requests (unless it’s a public route).
  • Uses Zustand/Redux/Mobx or another state management library to store and get the current accessToken.

Why it matters:

  • Keeps your API secure by attaching tokens.
  • Avoids manually adding headers in each component or API call.
  • Skips tokens for public routes like /login or /register.

3. Handle Token Refresh in Response Interceptor

// src/api/axios.response.ts
import type { AxiosResponse } from "axios";
import { refreshToken } from "./axios.helper";
import axios from "./axios.config";

const onResponse = (response: AxiosResponse) => response;

const onResponseError = async (error: any) => {
  const originalRequest = error?.config;

  if (
    error?.response?.status === 401 &&
    !originalRequest._retry &&
    !originalRequest.url.includes("/auth/refresh-token")
  ) {
    originalRequest._retry = true;

    const res = await refreshToken();

    if (res?.success) {
      const { accessToken } = res.tokens || {};
      originalRequest.headers.Authorization = `Bearer ${accessToken}`;
      return axios.request(originalRequest);
    }
  }

  return Promise.reject(error);
};

const response = { onResponse, onResponseError };
export default response;

What it does:

  • Catches 401 Unauthorized errors from the server.
  • If it’s due to an expired token, it triggers refreshToken().
  • Retries the failed request with the new access token.

Why it matters:

  • Users stay logged in smoothly without manually refreshing the page or logging in again.
  • Ensures token refresh happens only when necessary.

4. Add Refresh Token Logic (with Memoization)

// src/api/axios.helper.ts
import mem from "mem";
import axios from "axios";
import { useAuthStore } from "@/store/useAuthStore";

const baseUrl = API_BASE_URL;

export const refreshToken = mem(
  async () => {
    const { refreshToken } = useAuthStore.getState();
    try {
      if (!refreshToken) throw new Error("No refresh token found");

      const response = await axios.post(`${baseUrl}/auth/refresh-token`, {
        refreshToken,
      });

      const tokens = response?.data?.tokens;

      useAuthStore.setState({
        accessToken: tokens.accessToken,
        refreshToken: tokens.refreshToken,
      });

      return response.data;
    } catch (error) {
      console.error("Error refreshing token:", error);
      useAuthStore.setState({
        accessToken: null,
        refreshToken: null,
        isAuthenticated: false,
      });
    }
  },
  {
    maxAge: 10000, // Cache refresh calls for 10s
  },
);

What it does:

  • Sends a POST request to refresh the access token.
  • Updates the Zustand auth store with the new tokens.
  • Uses mem() to cache the request for 10 seconds so if multiple 401s happen, only one refresh request is sent.

Why it matters:

  • Prevents multiple simultaneous token refreshes, which can cause race conditions or overload the server.
  • Ensures consistent token updates across all parts of the app.

What is mem?

mem is a small utility library by Sindre Sorhus that memoizes (caches) function results — for both async and sync functions.

Without mem() — race condition example:
Imagine 3 API calls are made concurrently, all failing with 401 Unauthorized:

  • Each request triggers a call to refreshToken().
  • The server gets 3 identical refresh requests.
  • All 3 try to update the token, which can lead to race conditions or wasted calls.

With mem() — cached for a short time:

  • Only the first call to refreshToken() actually hits the server.
  • The rest get the same promise result (shared response).
  • After 10 seconds, it expires and will run again if needed.

This gives you: no duplicate token refreshes, better performance, and a safer, cleaner auth flow.

Final Thoughts

This Axios setup helps you:

  • Centralize and abstract API logic
  • Automatically refresh tokens when expired
  • Gracefully handle unauthorized requests
  • Improve DX by avoiding repetition

By separating concerns into small, testable pieces, your application becomes easier to maintain and scale as it grows.

Bonus Tips

  • Consider using axios.CancelToken or AbortController to cancel in-flight requests on component unmount.
  • Use React Query, TanStack Query, or SWR with this Axios instance for caching and retry logic.
  • Log out the user and redirect if refreshToken fails.