Efficient Refresh Token Implementation with React Query and Axios

elmehdiamlou

Elmehdi Amlou

Posted on September 19, 2024

Efficient Refresh Token Implementation with React Query and Axios

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 and onSuccess callbacks in useQuery 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

refresh token

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");
};


Enter fullscreen mode Exit fullscreen mode

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;


Enter fullscreen mode Exit fullscreen mode

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;
};


Enter fullscreen mode Exit fullscreen mode
  • lib/interfaces/auth.interface.ts


export interface IRefreshTokenResponse {
  accessToken: string;
  refreshToken: string;
}


Enter fullscreen mode Exit fullscreen mode

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,
  }),
};


Enter fullscreen mode Exit fullscreen mode

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";
    }
  }
};


Enter fullscreen mode Exit fullscreen mode
  • lib/interfaces/request.interface.ts


export interface IErrorResponse {
  message: string;
}


Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
elmehdiamlou
Elmehdi Amlou

Posted on September 19, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related