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
- Node.js API for authentication using JSON Web Tokens (JWT) and refresh tokens
- React Hooks Intro
- React Hooks API Reference
- useReducer hook
- Axios - Promise based HTTP client for the browser and node.js
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).
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)),
);
}
};