Customize useReducer React hook for asynchronous requests

Create a useReducer custom React hook to be able to dispatch JWT authenticated asynchronous requests with Axios. Add actions logging for development environment.

August 06, 2020

What is covered

Code repository

Online resources

Create a StoreProvider for the StoreContext

/frontend/src/store/reducer/StoreProvider.js is creating the context (line 8) for our app to store the state and the dispatch function in. Before returning the context provider (lines 21-25) we need to address the performance issues posed by forcing all children of this provider to rerender when a new object is created as the value prop. That’s why we need to memoize that object containing the new state and the dispatch function with the useMemo hook (lines17-19). initialState is imported from another file because it is also required by the authReducer.

import React, { useMemo } from 'react';
import PropTypes from 'prop-types';

import { authReducer } from './reducers';
import initialState from './initialState';
import useAxiosReducer from '../../hooks/useAxiosReducer';

export const StoreContext = React.createContext();

const StoreProvider = ({ children }) => {
  const [state, dispatch] = useAxiosReducer(
    authReducer,
    initialState,
    process.env.NODE_ENV === 'development',
  );

  const contextValue = useMemo(() => {
    return { state, dispatch };
  }, [state, dispatch]);

  return (
    <StoreContext.Provider value={contextValue}>
      {children}
    </StoreContext.Provider>
  );
};

StoreProvider.propTypes = {
  children: PropTypes.element.isRequired,
};

export default StoreProvider;

Adding the reducer and the actions to be dispatched

/frontend/src/store/reducer/reducers.js is matching the dispatched actions by type and is returning a new version of the state. The first two actions (lines 9-12) are matched by the type suffix and they deal with the isLoading and error keys of the state.

import match from 'conditional-expression';
import get from 'lodash.get';

import { LOGGED_IN, LOGGED_OUT, SET_NEW_TOKEN, FETCH_USERS, REFRESH_TOKEN } from './actionTypes';
import initialState from './initialState';

export const authReducer = (state, action) => 
  match(action.type)
    .includes('_REQUEST')
      .then({ ...state, isLoading:true, error: null })
    .includes('_FAILURE')
      .then({ ...state, isLoading: false, error: action.error })
    .equals(`${LOGGED_IN}_SUCCESS`)
      .then({ ...state, isLoading: false, jwtToken: get(action, 'data.jwtToken', null) })
    .equals(`${LOGGED_OUT}_SUCCESS`)
      .then({ ...initialState })
    .equals(SET_NEW_TOKEN)
      .then({ ...state, jwtToken: action.jwtToken })
    .equals(`${REFRESH_TOKEN}_SUCCESS`)
      .then({ ...state, isLoading: false, jwtToken: get(action, 'data.jwtToken', null) })
    .equals(`${FETCH_USERS}_SUCCESS`)
      .then({ ...state, isLoading: false, users: action.data })
    .else({ ...state });

/frontend/src/store/reducer/actions.js is defining the synchronous and asynchronous actions to be dispatched. I will describe in the next section how useAxiosReducer is detecting that payload special key and is acting accordingly.

import {
  LOGGED_IN,
  LOGGED_OUT,
  SET_NEW_TOKEN,
  REFRESH_TOKEN,
  FETCH_USERS,
} from './actionTypes';

export const logIn = async (dispatch, credentials) => {
  const res = await dispatch({
    type: LOGGED_IN,
    payload: {
      request: {
        url: '/login',
        method: 'post',
        body: { ...credentials },
      },
    },
  });

  return res;
};

export const logOut = async (dispatch) => {
  const res = await dispatch({
    type: LOGGED_OUT,
    payload: {
      request: {
        url: '/logout',
        method: 'get',
      },
    },
  });

  return res;
};

export const setNewToken = (dispatch, jwtToken) => {
  dispatch({
    type: SET_NEW_TOKEN,
    jwtToken,
  });
};

export const refreshToken = async (dispatch) => {
  const res = await dispatch({
    type: REFRESH_TOKEN,
    payload: {
      request: {
        url: '/refresh-token',
        method: 'post',
      },
    },
  });

  return res;
};

export const fetchUsers = (dispatch, query = null) => {
  dispatch({
    type: FETCH_USERS,
    payload: {
      request: {
        url: '/fetch-users',
        method: 'get',
      },
    },
  });
};

Create the useAxiosReducer

In essence, what /frontend/src/hooks/useAxiosReducer.js is doing is taking the dispatch function provided by the useReducer React hook and modifying the way each action is dispatched based on its shape. If the action has a valid request object under payload, it creates 3 new actions out of it (lines 6-10) and it is dispatching those in order. Otherwise, it is simply dispatching the action using the default dispatch function (lines 36-37).

To be able to log the console.group at lines (86-90) as the previous state, the action and the current state, we need to persist the current one under a React ref object instantiated by the useRef hook (line 15). We delegate this task to the saveAction memoized function (lines 17-26).

Dispatched actions logger

The meat of the customized dispatchWithLogging function is when we are preparing the request (lines 45-53), creating the axios client by providing it the dispatchWithLogging function the state object (line 55), and using it on line 56 to issue the HTTP request. Depending on the result we return something else after dispatching the SUCCESS or FAILURE action (line 71).

On line 96 we need to assign to the ref object the current state under the current key.

import { useReducer, useMemo, useCallback, useRef } from 'react';
import get from 'lodash.get';

import createAxiosClient from '../utils/createAxiosClient';

const createAsyncTypes = (type) => [
  { type: `${type}_REQUEST` },
  { type: `${type}_SUCCESS` },
  { type: `${type}_FAILURE` },
];

export default (reducer, initialState, hasLogger = false) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const preState = useRef();

  const saveAction = useCallback((action) => {
    const actionType = typeof action === 'object' ? action.type : dispatch;

    preState.current.actions = preState.current.actions || [];
    preState.current.actions.push({
      actionType,
      action,
      state: preState.current.state,
    });
  }, []);

  const dispatchWithLogging = useCallback(
    async (action) => {
      const url = get(action, 'payload.request.url', null);
      const method = get(action, 'payload.request.method', null);
      const body = get(action, 'payload.request.body', null);
      let res = null; 

      if (!url || !method) {
        saveAction(action);
        dispatch(action);
      } else {
        const asyncActions = createAsyncTypes(action.type);

        try {
          saveAction(asyncActions[0]);
          dispatch(asyncActions[0]);

          const request = {
            method,
            url,
            withCredentials: true,
          };

          if (body) {
            request.data = body;
          }

          const axios = createAxiosClient(dispatchWithLogging, preState.current.state);
          res = await axios(request);

          const successAction = { ...asyncActions[1], data: res.data };

          saveAction(successAction);
          dispatch(successAction);
        } catch (error) {
          res = { error: get(error, 'response.data', error) };
          const failureAction = { ...asyncActions[2], error };

          saveAction(failureAction);
          dispatch(failureAction);
        }
      }

      return res;
    },
    [saveAction],
  );

  useMemo(() => {
    if (!hasLogger || !preState.current) return;

    for (let i = 0; i < preState.current.actions.length; i++) {
      const {
        actionType,
        state: previousState,
        action,
      } = preState.current.actions[i];

      console.group(`${actionType}`);
      console.log('%c Previous State', 'color: red;', previousState);
      console.log('%c Action', 'color: blue;', action);
      console.log('%c Current State', 'color: green;', state);
      console.groupEnd();

      preState.current.actions = [];
    }
  }, [state, hasLogger]);

  preState.current = { ...preState.current, state };

  return [state, dispatchWithLogging];
};

Adding createAxiosClient to setup interceptors for authentication and to refresh the JWT token

/frontend/src/utils/createAxiosClient.js is creating an axios client (lines 13-19) by providing it with the dispatch and state stored under the StoreContext and called with from the useAxiosReducer hook.

The request interceptor of the axiosApiInstance (lines 21-32) is taking the jwtToken from state and is adding the Authorization header to the request. The response interceptor of the axiosApiInstance (lines 34-66) is looking for the 401 Unauthorized status and Invalid JWT token message to be able to try a token refresh (lines 47-51). If refreshing the token is successful (lines 53-57) we set the new token and retry the previous request automatically.

import axios from 'axios';
import get from 'lodash.get';

import { setNewToken, logOut } from '../store/reducer/actions';
const language = get(window, 'navigator.language', 'en').slice(0, 2);

const baseURL =
  process.env.NODE_ENV === 'production'
    ? process.env.REACT_APP_PROD_REST_URL
    : process.env.REACT_APP_DEV_REST_URL;

const createAxiosClient = (dispatch, state) => {
  const axiosApiInstance = axios.create({
    baseURL,
    headers: {
      'Content-Type': 'application/json',
      'Accept-Language': language
    },
  });

  axiosApiInstance.interceptors.request.use(
    (config) => {
      if (state.jwtToken && !config.headers['Authorization']) {
        config.headers['Authorization'] = `Bearer ${state.jwtToken}`;
      }
      
      return config;
    },
    (error) => {
      Promise.reject(error);
    },
  );

  axiosApiInstance.interceptors.response.use(
    (response) => response,
    async (error) => {
      const previousRequest = error.config;

      if (
        get(error, 'response.status', null) === 401 &&
        get(error, 'response.data.message', null) === 'Invalid JWT token' &&
        !previousRequest._retry
      ) {
        previousRequest._retry = true;

        try {
          const res = await axios({
            url: `${baseURL}/refresh-token`,
            method: 'post',
            withCredentials: true,
          });

          if (res.status === 200) {
            setNewToken(dispatch, res.data.jwtToken);
            previousRequest.headers['Authorization'] = `Bearer ${res.data.jwtToken}`;
            return axiosApiInstance(previousRequest);
          }
        } catch (error) {
          logOut(dispatch);
          return Promise.reject(error);
        }
      }
      
      return Promise.reject(error);
    },
  );

  return axiosApiInstance;
};

export default createAxiosClient;

Dispatching actions to update the context state

You can take a peek at /frontend/src/screens/Login/index.js file to see how the actions are dispatched from the Login screen (fragment extracted bellow from lines 37-61 on the file).

 useEffect(() => {
    const checkTokens = async () => {
      if (state.jwtToken) {
        history.replace(from);
      } else {
        if (retry < 1) {
          setRetry(1);
          await refreshToken(dispatch);
          setIsChecking(false);
        }
      }
    };

    checkTokens();
  }, [state, from, history, dispatch, retry]);

  const handleSubmit = async (values, actions) => {
    const res = await logIn(dispatch, values);
    if (res.error) {
      actions.setFieldError(
        'email',
        get(res.error, 'message', formatMessage(messages.genericError)),
      );
    }
  };