Hopp til hovedinnhold

Is this the last reducer you'll ever write?

I don't know about you, but most of my Redux reducers look like this:

import * as actions from '../actions';
const initialState = {
  someData: null,
  metadata: {
    pending: false,
    loaded: false,
    error: false,
  },
};
function someReducer(state = initialState, action) {
  switch (action.type) {
    case actions.SOME_REQUEST:
      return {
        ...state,
        someData: null,
        metadata: {
          ...state.metadata,
          pending: true,
          loaded: false,
          error: false,
        },
      };
    case actions.SOME_RESPONSE:
      return {
        ...state,
        someData: action.payload,
        metadata: {
          ..state.metadata,
          pending: false,
          loaded: true,
          error: false,
        },
      };
    case actions.SOME_ERROR:
      return {
        ...state,
        someData: '',
        metadata: {
          ...state.metadata,
          pending: false,
          loaded: false,
          error: action.payload,
        },
      };
    default:
      return state;
  }
}

The usecase is pretty simple - it's dealing with an asynchronous action that either returns some data or some error. Like purchasing something. Or logging in. Don't you think this is a lot of boilerplate for a pretty common use case?

This article will show you how to stop writing boiler plate, while still having a maintainable code base.

Step 1: Standardizing async actions

We ask our server for stuff every once in a while - and we bet you do too. A typical work flow when you're doing this is creating three actions with matching action creators:

const SOME_REQUEST = "SOME_REQUEST";
const someRequest = () => ({
  type: SOME_REQUEST,
});
const SOME_RESPONSE = "SOME_RESPONSE";
const someResponse = (payload) => ({
  type: SOME_RESPONSE,
  payload,
});
const SOME_ERROR = "SOME_ERROR";
const someError = (payload) => ({
  type: SOME_ERROR,
  payload,
});

This gets tedious after a while, especially when it creates a matching amount of work in the reducer, and in its maticulous test suite.

Let's create a function that makes these for us:

import { upperCase } from "change-case";
const createAction = (action) => ({
  [action.upperCase()]: action.toUpperCase(),
  [toCamelCase(action)]: (payload) => ({
    type: action.toUpperCase(),
    payload,
  }),
});
const createNetworkAction = (name) => ({
  ...createAction(`${name}_REQUEST`),
  ...createAction(`${name}_RESPONSE`),
  ...createAction(`${name}_ERROR`),
});

Now, instead of writing those 15 lines above for each server call you want to do, you can call createNetworkAction('SOME_ACTION'). Pretty neat, eh? 🇨🇦

Step 2: Create a metadata reducer

Since all async actions now follow the same strict naming standard, we can do even more cool stuff. Let's create a shared reducer that deals with all our metadata!

What I'm going to how you next is basically a generalized version of my first example - a "typical reducer":

import { camelCase } from "change-case";
export default function metadataReducer(state = {}, action) {
  const actionName = camelCase(
    action.type.substring(0, action.type.lastIndexOf("_"))
  );
  const actionType = action.type.substring(action.type.lastIndexOf("_") + 1);
  let updated = {};
  switch (actionType) {
    case "REQUEST":
      updated = {
        pending: true,
        loaded: false,
        error: false,
      };
      break;
    case "RESPONSE":
      updated = {
        pending: false,
        loaded: true,
        error: false,
      };
      break;
    case "ERROR":
      updated = {
        pending: false,
        loaded: false,
        error: action.payload,
      };
      break;
    default:
      return state;
  }
  return {
    ...state,
    [actionName]: updated,
  };
}

It might look a bit overwhelming at first, but what is says is basically:

  1. If the action ends with _REQUEST, set the state of that request to loading
  2. If the action ends with _RESPONSE, set the state of that request to successful
  3. If the action ends with _ERROR, set the state of that request to erroneous.

The great thing about this is that you only need to implement this once (hey, copy and paste if you want), and you're done!

Step 3: Create selectors!

The biggest downside with this approach is that you have to add a lot of checks for whether or not the request has been made (the request isn't reflected in the reducer before you have requested it). Luckily, we can get around that by following the selector pattern, with a few clever tricks:

import { createSelector } from "reselect";

const createMetadataSelector = (slice) => (state) => {
  return state.metadata[slice]
    ? state.metadata[slice]
    : { pending: false, fetched: false, error: false };
};

// Here's an example selector
const isCustomerPending = createSelector(
  createMetadataSelector("fetchCustomer"),
  (metadata) => metadata.pending
);

This way, we make sure we always have sane default values!

Use it!

Now, you got a reducer that keeps track of all of your network metadata! It's simple to write selectors for (isCustomerFetching, isPurchasingProduct etc), and more importantly, even simpler to deal with the actual data you're fetching!

I created a simple project implementing this.

I hope this article inspired you to simplifying your redux code quite a bit. Thanks for reading!

Did you like the post?

Feel free to share it with friends and colleagues