While using React Query in a given project for asynchronous state management, which makes fetching, caching, synchronizing, and updating server state more straightforward and efficient as mentioned in the documentation, and by using Axios as a data fetching library to seamlessly interact with APIs. To implement a stateless authentication flow, by handling the access token in every request's headers using a global Axios request interceptor, When it comes to adding refresh token logic, why not again use a global Axios response interceptor as well?
It seems to be working, but using only Axios interceptors for refresh token logic has a drawback: it doesnβt handle the onSuccess
and onError
callbacks. Imagine we have crucial operations that need to be handled in callbacks, such as performing optimistic updates by adding a record upon successful request or rolling back changes if an error persists, for instance, in a mutation that encounters a 401 error. If these operations are not processed properly due to token refreshes solely with Axios interceptors, they won't be effective after retrying the request.
The
onError
andonSuccess
callbacks inuseQuery
were deprecated in v4 and removed in v5 due to issues causing bugs and unintended side effects.
To implement efficient refresh token functionality and address specific cases, like the callback use case mentioned above, that may not be managed effectively without integrating token refresh logic with React Query.
Let's delve into the details, starting with a quick setup and extending to incorporating refresh token with React Query.
Getting started
Let's begin by adding some utilities for handling tokens. You can store tokens in either local storage or cookies, though httpOnly
cookies are generally preferred over local storage for security reasons. However, the choice depends on your specific use case.
For this example, we'll use localStorage
.
- lib/utils/tokens.ts
export const setAccessToken = (token: string): void => {
localStorage.setItem("access_token", token);
};
export const getAccessToken = (): string | null => {
return typeof localStorage === "object"
? localStorage.getItem("access_token")
: null;
};
export const removeAccessToken = (): void => {
if (getAccessToken() != null) localStorage.removeItem("access_token");
};
export const setRefreshToken = (token: string): void => {
localStorage.setItem("refresh_token", token);
};
export const getRefreshToken = (): string | null => {
return typeof localStorage === "object"
? localStorage.getItem("refresh_token")
: null;
};
export const removeRefreshToken = (): void => {
if (getRefreshToken() != null) localStorage.removeItem("refresh_token");
};
The first step is to create a shared Axios instance and bound to it a global request interceptor, which will intercept requests and add the access token to the authorization header.
- lib/utils/axios.ts
import Axios, { AxiosRequestConfig } from "axios";
import { getAccessToken } from "./tokens";
export const axios = Axios.create({
baseURL: "https://example.com",
});
const authRequestInterceptor = (config: AxiosRequestConfig) => {
if (config.headers) {
config.headers["Content-Type"] = "application/json";
config.headers["Timezone-Val"] =
Intl.DateTimeFormat().resolvedOptions().timeZone;
const token = getAccessToken();
if (token) {
config.headers["authorization"] = `Bearer ${token}`;
config.withCredentials = true;
}
}
return config;
};
axios.interceptors.request.use(authRequestInterceptor);
export default axios;
Next, we can import the Axios instance we set up and use it to call APIs in our services, like the following example for the refresh-token endpoint.
- lib/services/auth.service.ts
import axios from "../utils/axios";
import { IRefreshTokenResponse } from "../interfaces/auth.interface";
export const refreshToken = async (refreshToken: string) => {
const { data } = await axios.post<IRefreshTokenResponse>(
`/auth/refresh-token`,
refreshToken
);
return data;
};
- lib/interfaces/auth.interface.ts
export interface IRefreshTokenResponse {
accessToken: string;
refreshToken: string;
}
Now, let's configure the properties of the query client configuration for the QueryClient
, which will be passed as a prop to the QueryClientProvider
that wraps the application.
The interesting part here is that we can leverage the onError
global event at the level of the global callbacks in QueryCache
and MutationCache
to implement the refresh token functionality.
- lib/utils/query-client.ts
import { MutationCache, QueryCache } from "@tanstack/react-query";
import { mutationErrorHandler, queryErrorHandler } from "../error-handler";
export const queryClientConfig = {
defaultOptions: {
queries: {
retry: false,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: false,
refetchIntervalInBackground: false,
suspense: false,
refetchInterval: 0,
cacheTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
queryCache: new QueryCache({
onError: queryErrorHandler,
}),
mutationCache: new MutationCache({
onError: mutationErrorHandler,
}),
};
When an error occurs in a query or mutation request, the queryErrorHandler
and mutationErrorHandler
functions kick in to handle it. These functions act as wrappers around the main errorHandler
function (mentioned below), passing along the error details and either the query or mutation that failed.
- lib/utils/error-handler.ts
import { refreshToken } from "../services/auth.service";
import { IErrorResponse } from "../interfaces/request.interface";
import { AxiosError, AxiosRequestConfig } from "axios";
import { Mutation, Query } from "@tanstack/react-query";
import {
setAccessToken,
removeAccessToken,
getRefreshToken,
setRefreshToken,
removeRefreshToken,
} from "./tokens";
let isRedirecting = false;
let isRefreshing = false;
let failedQueue: {
query?: Query;
mutation?: Mutation<unknown, unknown, unknown, unknown>;
variables?: unknown;
}[] = [];
const errorHandler = (
error: unknown,
query?: Query,
mutation?: Mutation<unknown, unknown, unknown, unknown>,
variables?: unknown
) => {
const { status, data } = (error as AxiosError<IErrorResponse>).response!;
if (status === 401) {
if (mutation) refreshTokenAndRetry(undefined, mutation, variables);
else refreshTokenAndRetry(query);
} else console.error(data?.message);
};
export const queryErrorHandler = (error: unknown, query: Query) => {
errorHandler(error, query);
};
export const mutationErrorHandler = (
error: unknown,
variables: unknown,
context: unknown,
mutation: Mutation<unknown, unknown, unknown, unknown>
) => {
errorHandler(error, undefined, mutation, variables);
};
const processFailedQueue = () => {
failedQueue.forEach(({ query, mutation, variables }) => {
if (mutation) {
const { options } = mutation;
mutation.setOptions({ ...options, variables });
mutation.execute();
}
if (query) query.fetch();
});
isRefreshing = false;
failedQueue = [];
};
const refreshTokenAndRetry = async (
query?: Query,
mutation?: Mutation<unknown, unknown, unknown, unknown>,
variables?: unknown
) => {
try {
if (!isRefreshing) {
isRefreshing = true;
failedQueue.push({ query, mutation, variables });
const { accessToken, refreshToken: newRefreshToken } = await refreshToken(
{
refreshToken: getRefreshToken()!,
}
);
setAccessToken(accessToken);
setRefreshToken(newRefreshToken);
processFailedQueue();
} else failedQueue.push({ query, mutation, variables });
} catch {
removeAccessToken();
removeRefreshToken();
if (!isRedirecting) {
isRedirecting = true;
window.location.href = "/auth/session-expired";
}
}
};
- lib/interfaces/request.interface.ts
export interface IErrorResponse {
message: string;
}
If the error status is 401
, which indicates an expired or invalid access token, the errorHandler
delegates the task to refreshTokenAndRetry
. This is the point where the system tries to refresh the user's token to regain authorization without interrupting the user experience.
The refreshTokenAndRetry
function handles the actual token refresh. If it detects that no other token refresh is currently happening, it starts the process by adding the failed request (query or mutation) to a queue. It then attempts to refresh the token using the saved refresh token. Once the new tokens are retrieved, they are stored, and all the failed requests in the queue are retried. If refreshing the token fails, it removes the tokens and redirects the user to a session expiration page, requiring them to log in again.
Conclusion
To implement efficient refresh token functionality with React Query, a key approach is to leverage the global onError
event in QueryCache
and MutationCache
. This allows you to handle token expiration centrally by intercepting failed requests and initiating the refresh token logic. Additionally, by maintaining a queue of failed requests during token refresh, you can ensure that simultaneous failed requests are properly retried once a new token is acquired. This method efficiently handles multiple requests, ensuring they are retried in the correct order and maintaining a smooth user experience.
Top comments (0)