Search code examples
javascriptreactjsreduxaxiosredux-toolkit

Refresh the access token using redux-toolkit asyncThunks


import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axiosInstance from "../utils/axiosInstance";

export const logoutUser = createAsyncThunk(
  "users/logoutUser",
  async (_, { rejectWithValue, dispatch }) => {
    try {
      const response = await axiosInstance.post("/users/logout");
      return response.data;
    } catch (error) {
      console.log(error.response.status);
      if (error.response.status === 401) {
        try {
          await dispatch(refreshUserToken());

          const retryResponse = await dispatch(logoutUser());
          return retryResponse;
        } catch (refreshError) {
          return rejectWithValue(refreshError.message);
        }
      } else {
        return rejectWithValue(error.message);
      }
    }
  }
);

export const refreshUserToken = createAsyncThunk(
  "users/refreshUserToken",
  async (_, { rejectWithValue }) => {
    try {
      const response = await axiosInstance.post("/users/refresh-token");
      console.log("refreshToken->", response.data);
      return response.data;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const initialState = {
  user: {},
  status: "idle",
  error: null,
};

const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {},
  extraReducers(builder) {
    builder
      .addCase(logoutUser.pending, (state, action) => {
        state.status = "loading";
      })
      .addCase(logoutUser.fulfilled, (state, action) => {
        state.status = "success";
        console.log("case f logout->", action.payload); // log statement
        state.user = {};
      })
      .addCase(logoutUser.rejected, (state, action) => {
        state.error = action.payload;
      })
      .addCase(refreshUserToken.pending, (state, action) => {
        state.status = "loading";
      })
      .addCase(refreshUserToken.fulfilled, (state, action) => {
        state.status = "success";
        console.log("case f refreshToken->", action.payload); // log statement
      })
      .addCase(refreshUserToken.rejected, (state, action) => {
        state.error = action.payload;
      });
  },
});

export const getUserState = (state) => state.user.status;
export const getUserError = (state) => state.user.error;
export const getUser = (state) => state.user.user;

export const {} = userSlice.actions;

export default userSlice.reducer;

below is my axios instance.

const axiosInstance = axios.create({
    baseURL: "http://localhost:8082/api",
    withCredentials: true
});

I am new to Redux-Toolkit, so I'm confused. In the logoutUser asyncThunk I'm checking if the error code I receive is 401 (access toke expired), then I'm making a request for a refresh token by calling the refreshUserToken thunk inside the logoutUser thunk, but that is causing the logoutThunk to run twice. How can I correct this? Or is there a better way to handle refresh of the token with asyncThunks?

Note: I'm using cookies to send and receive access and refresh token from the server.


Solution

  • Instead of re-dispatching the logoutUser action which runs the Thunk action at least one additional time you could re-call just the endpoint.

    Example:

    export const logoutUser = createAsyncThunk(
      "users/logoutUser",
      async (_, { rejectWithValue, dispatch }) => {
        try {
          const { data } = await axiosInstance.post("/users/logout");
          return data;
        } catch(error) {
          console.log(error.response.status);
          if (error.response.status === 401) {
            try {
              // Attempt token refresh, unwrap result
              await dispatch(refreshUserToken()).unwrap();
    
              // Retry original request
              const { data } = await axiosInstance.request(error.config);;
              return data;
            } catch(refreshError) {
              return rejectWithValue(refreshError.message);
            }
          } else {
            return rejectWithValue(error.message);
          }
        }
      }
    );
    

    To get away from implementing this retry behavior all over the place you could try using a response interceptor to abstract and handle the retry logic.

    Example:

    axiosInstance.js

    import axios from 'axios';
    
    const axiosInstance = axios.create({
      baseURL: 'http://localhost:8082/api',
      withCredentials: true,
    });
    
    export default axiosInstance;
    

    userSlice.js

    export const logoutUser = createAsyncThunk(
      "users/logoutUser",
      async (_, { rejectWithValue }) => {
        try {
          const { data } = await axiosInstance.post("/users/logout");
          return data;
        } catch(error) {
          return rejectWithValue(error.message);
        }
      }
    );
    

    main.jsx

    import React from 'react'
    import ReactDOM from 'react-dom/client'
    import { BrowserRouter } from 'react-router-dom'
    import { Provider } from 'react-redux';
    
    import App from './App.jsx'
    import axiosInstance from './utils/axiosInstance';
    import { store } from './app/store';
    import { refreshUserToken } from './features/userSlice';
    
    // Setup response interceptors for auth retry/token refresh
    axiosInstance.interceptors.response.use(
      (response) => response,
      async (error) => {
        if (error.response.status === 401) {
          // Attempt token refresh, unwrap result
          await store.dispatch(refreshUserToken()).unwrap();
    
          // Retry original request
          return axiosInstance.request(error.config);
        }
        return Promise.reject(error);
      }
    );
    
    ReactDOM.createRoot(document.getElementById('root')).render(
      <React.StrictMode>
        <BrowserRouter>
          <Provider store={store}>
            <App />
          </Provider>
        </BrowserRouter>
      </React.StrictMode>,
    );