How to Set Up Axios with Token Refresh, Interceptors, and Global Error Handling in React
Production-ready API client with auth and refresh
- 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
axiosInstancewith 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
Authorizationheader 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
/loginor/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.CancelTokenorAbortControllerto 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
refreshTokenfails.