React/Redux Tips: Better Way to Handle Loading Flags in Your Reducers
  • July 23, 2020
  • Paja Aleksic
  • React

TLDR; Use a separate reducer to store all isFetching flags instead of polluting all of your reducers.

So like everyone, you are using React for frontend, Redux for state management, and given that your app is complex enough, then it’s very likely for you to have run into this piece of code:

// todo/actions.js
export const getTodos = (dispatch) => () => {
  dispatch({ type: 'GET_TODOS_REQUEST' });
  return fetch('/api/v1/todos')
    .then((todos) => dispatch({ type: 'GET_TODOS_SUCCESS', payload: todos })
    .catch((error) => dispatch({ type: 'GET_TODOS_FAILURE', payload: error, error: true });
};

// todo/reducer.js
const initialState = { todos: [] };
export const todoReducer = (state = initialState, action) => {
  switch(action.type) {
    case 'GET_TODOS_REQUEST': 
      return { ...state, isFetching: true };
    case 'GET_TODOS_SUCCESS': 
      return { ...state, isFetching: false, todos: action.payload };
    case 'GET_TODOS_FAILURE': 
      return { ...state, isFetching: false, errorMessage: action.payload.message };
    default: 
      return state;
  }
};

This code would work fine until your app requires a lot more API calls — then half of your reducer code would be code to handle isFetching/errorMessage ☹️

Enter loading reducer

I solved this by creating a loading reducer that stores all API request states:

// api/loadingReducer.js
const loadingReducer = (state = {}, action) => {
  const { type } = action;
  const matches = /(.*)_(REQUEST|SUCCESS|FAILURE)/.exec(type);
// not a *_REQUEST / *_SUCCESS / *_FAILURE actions, so we ignore them
if (!matches) return state;
const [, requestName, requestState] = matches;
return {
...state,
// Store whether a request is happening at the moment or not
// e.g. will be true when receiving GET_TODOS_REQUEST
// and false when receiving GET_TODOS_SUCCESS / GET_TODOS_FAILURE
[requestName]: requestState === 'REQUEST',
};
};

Then we can access those loading states using a selector in our component:

// api/selectors.js
import _ from 'lodash';
export const createLoadingSelector = (actions) => (state) => {
  // returns true only when all actions is not loading
  return _(actions)
    .some((action) => _.get(state, `api.loading.${action}`));
};

// components/todos/index.js
import { connect } from 'react-redux';
import Todos from './Todos';
import { createLoadingSelector } from '../../redux/api/selectors';

// Show loading on GET_TODOS_REQUEST
const loadingSelector = createLoadingSelector(['GET_TODOS']);
const mapStateToProps = (state) => ({ isFetching: loadingSelector(state) });
export default connect(mapStateToProps)(Todos);

Since we don’t need to maintain any isFetching flags, now our reducers will only need to care about storing data:

// todo/reducer.js
const initialState = { todos: [] };
export const todoReducer = (state = initialState, action) => {
  switch(action.type) {
    case 'GET_TODOS_SUCCESS': 
      return { ...state, todos: action.payload };
    default: 
      return state;
  }
};

Ok maybe not that much simpler in this test code, but when your app requires you to make requests to more than 20+ APIs, it will make your life a tiny bit easier 😏

Can we combine API calls?

When building a real-world™ app, we might need to combine API calls (e.g. Only display todos page when both getUser and getTodos requests succeed). This is trivial with this approach:

// components/todos/index.js
import { connect } from 'react-redux';
import Todos from './Todos';
import { createLoadingSelector } from '../../redux/api/selectors';

// Show loading when any of GET_TODOS_REQUEST, GET_USER_REQUEST is active
const loadingSelector = createLoadingSelector(['GET_TODOS', 'GET_USER']);
const mapStateToProps = (state) => ({ isFetching: loadingSelector(state) });
export default connect(mapStateToProps)(Todos);

How about error messages?

Handling API error messages is similar to handling loading flags, except when selecting which message to display:

// api/errorReducer.js
export const errorReducer = (state = {}, action) => {
  const { type, payload } = action;
  const matches = /(.*)_(REQUEST|FAILURE)/.exec(type);

  // not a *_REQUEST / *_FAILURE actions, so we ignore them
  if (!matches) return state;

  const [, requestName, requestState] = matches;
  return {
    ...state,
    // Store errorMessage
    // e.g. stores errorMessage when receiving GET_TODOS_FAILURE
    //      else clear errorMessage when receiving GET_TODOS_REQUEST
    [requestName]: requestState === 'FAILURE' ? payload.message : '',
  };
};

// api/selectors.js
import _ from 'lodash';
export const createErrorMessageSelector = (actions) => (state) => {
  // returns the first error messages for actions
  // * We assume when any request fails on a page that
  //   requires multiple API calls, we shows the first error
  return _(actions)
    .map((action) => _.get(state, `api.error.${action}`))
    .compact()
    .first() || '';
};