Axios Interceptors
Axios interceptors handle cross-cutting concerns like authentication, error handling, and request/response transformation. This guide shows how to integrate them with Query Cache Flow.
Basic Setup
Create the Axios Instance
// src/services/axios.ts
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
// Types for KUBB compatibility
export type RequestConfig<TData = unknown> = {
baseURL?: string;
url?: string;
method: 'GET' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS';
params?: unknown;
data?: TData | FormData;
responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream';
signal?: AbortSignal;
headers?: AxiosRequestConfig['headers'];
};
export type ResponseConfig<TData = unknown> = {
data: TData;
status: number;
statusText: string;
headers?: AxiosResponse['headers'];
};
export type ResponseErrorConfig<TError = unknown> = AxiosError<TError>;
// Create instance with base config
export const axiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:3000/api',
headers: {
'Content-Type': 'application/json',
},
});
Request Interceptors
Authentication
Add the auth token to every request:
import { useSession } from 'src/features/auth/stores/session';
axiosInstance.interceptors.request.use(
(config) => {
const token = useSession.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
Language/Locale
Include the user's language preference:
import i18next from 'i18next';
axiosInstance.interceptors.request.use(
(config) => {
config.headers['Accept-Language'] = i18next.language;
return config;
},
(error) => Promise.reject(error)
);
Combined Request Interceptor
axiosInstance.interceptors.request.use(
(config) => {
// Auth
const token = useSession.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Locale
config.headers['Accept-Language'] = i18next.language;
// Request ID for tracing
config.headers['X-Request-ID'] = crypto.randomUUID();
return config;
},
(error) => Promise.reject(error)
);
Response Interceptors
Error Handling
Handle common HTTP errors globally:
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
// Handle 401 Unauthorized
if (error.response?.status === 401) {
// Don't redirect on login page
if (error.config?.url !== '/auth/login') {
console.log('Session expired - clearing auth');
useSession.getState().clear();
// Optional: redirect to login
window.location.href = '/login';
}
}
// Handle 403 Forbidden
if (error.response?.status === 403) {
console.log('Access denied');
// Optional: redirect to unauthorized page
}
// Handle 500+ Server Errors
if (error.response?.status && error.response.status >= 500) {
console.error('Server error:', error.response.data);
// Optional: show global error toast
}
throw error;
}
);
Token Refresh
Automatically refresh expired tokens:
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
function subscribeTokenRefresh(callback: (token: string) => void) {
refreshSubscribers.push(callback);
}
function onTokenRefreshed(token: string) {
refreshSubscribers.forEach((callback) => callback(token));
refreshSubscribers = [];
}
axiosInstance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
// Handle 401 with token refresh
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Wait for refresh to complete
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
originalRequest.headers!.Authorization = `Bearer ${token}`;
resolve(axiosInstance(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = useSession.getState().refreshToken;
const response = await axios.post('/auth/refresh', { refreshToken });
const { accessToken } = response.data;
useSession.getState().setTokens(accessToken, refreshToken);
onTokenRefreshed(accessToken);
originalRequest.headers!.Authorization = `Bearer ${accessToken}`;
return axiosInstance(originalRequest);
} catch (refreshError) {
useSession.getState().clear();
window.location.href = '/login';
throw refreshError;
} finally {
isRefreshing = false;
}
}
throw error;
}
);
Integration with Query Cache Flow
Cache Invalidation on Auth Changes
When authentication changes, invalidate user-specific caches:
// In your auth store or logout function
function logout() {
// Clear tokens
useSession.getState().clear();
// Clear ALL cached data (user-specific)
queryClient.clear();
// Redirect
window.location.href = '/login';
}
Server-Driven Invalidation
Handle cache invalidation hints from the server:
axiosInstance.interceptors.response.use(
(response) => {
// Check for cache invalidation header
const invalidateEntities = response.headers['x-invalidate-entities'];
if (invalidateEntities) {
const entities = invalidateEntities.split(',');
invalidateAffectedEntities(entities);
}
return response;
},
(error) => {
throw error;
}
);
Rate Limiting Awareness
Handle 429 Too Many Requests:
axiosInstance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'];
if (retryAfter) {
const delay = parseInt(retryAfter, 10) * 1000;
// Wait and retry
await new Promise((resolve) => setTimeout(resolve, delay));
return axiosInstance(error.config!);
}
}
throw error;
}
);
The Client Function
Export the client function for KUBB:
export const axiosClient = async <TData, TError = unknown, TVariables = unknown>(
config: RequestConfig<TVariables>
): Promise<ResponseConfig<TData>> => {
return axiosInstance
.request<TData, ResponseConfig<TData>>(config)
.catch((error: AxiosError<TError>) => {
throw error;
});
};
// Expose config methods
axiosClient.getConfig = () => axiosInstance.defaults;
axiosClient.setConfig = (config: Partial<AxiosRequestConfig>) => {
Object.assign(axiosInstance.defaults, config);
};
export default axiosClient;
Complete Example
// src/services/axios.ts
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import i18next from 'i18next';
import { useSession } from 'src/features/auth/stores/session';
import { invalidateAffectedEntities } from 'src/queries/entityMap';
// Types
export type RequestConfig<TData = unknown> = {
baseURL?: string;
url?: string;
method: 'GET' | 'PUT' | 'PATCH' | 'POST' | 'DELETE' | 'OPTIONS';
params?: unknown;
data?: TData | FormData;
responseType?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream';
signal?: AbortSignal;
headers?: AxiosRequestConfig['headers'];
};
export type ResponseConfig<TData = unknown> = {
data: TData;
status: number;
statusText: string;
headers?: AxiosResponse['headers'];
};
export type ResponseErrorConfig<TError = unknown> = AxiosError<TError>;
// Create instance
export const axiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_URL,
});
// Request interceptor
axiosInstance.interceptors.request.use(
(config) => {
// Add auth token
const token = useSession.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add locale
config.headers['Accept-Language'] = i18next.language;
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
axiosInstance.interceptors.response.use(
(response) => {
// Handle server cache invalidation hints
const invalidateEntities = response.headers['x-invalidate-entities'];
if (invalidateEntities) {
invalidateAffectedEntities(invalidateEntities.split(','));
}
return response;
},
(error: AxiosError) => {
// Handle 401/403
if (error.response?.status === 401 || error.response?.status === 403) {
if (error.config?.url !== '/auth/login') {
useSession.getState().clear();
}
}
throw error;
}
);
// Export client for KUBB
export const axiosClient = async <TData, TError = unknown, TVariables = unknown>(
config: RequestConfig<TVariables>
): Promise<ResponseConfig<TData>> => {
return axiosInstance.request<TData, ResponseConfig<TData>>(config);
};
export default axiosClient;
Best Practices
1. Keep Interceptors Focused
Each interceptor should do one thing. Combine concerns thoughtfully.
2. Handle Errors Gracefully
Don't swallow errors - always throw after handling.
3. Avoid Circular Dependencies
If interceptors need Query Cache Flow functions, use dynamic imports or dependency injection.
4. Test Interceptors
describe('axios interceptors', () => {
it('adds auth header when token exists', async () => {
useSession.getState().setTokens('test-token', 'refresh');
const request = await axiosInstance.request({ url: '/test' });
expect(request.config.headers.Authorization).toBe('Bearer test-token');
});
});
Summary
Axios interceptors complement Query Cache Flow by handling:
- Authentication - Token injection and refresh
- Error handling - Global error responses
- Localization - Language headers
- Cache coordination - Server-driven invalidation
Together, they create a robust data layer that handles auth, caching, and errors transparently.