Learning Redux Middlewares By Reading Their Source

in react

For a long time I thought Redux middlewares are not that relevant to my day-to-day programming work. After all I know how to use all the middlewares I need, so what's the point learning how to write them?

But after I dove in I found middlewares both easy to understand and a useful addition to my Redux toolbox. And I think after reading the source code for the following 3 popular middlewares you'll feel that way too.

redux-thunk

Let's start with redux-thunk, which is the first middleware I used and one of the highest rated (just below redux-saga, which deserves a post of its own).

Code including blank lines is just 14 lines, so I'll just paste the entire thing:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

Wow that was quick. And the real action happens in just 4 lines of code:

The second line defines a middleware. Each middleware is a 3 step function taking first a store (the dispatch and getstate object), returning a new function that takes a next and returns a new function that takes an action. In the middleware code we therefore have access to the current action that was just dispatched, to the store and to the rest of the middleware chain.

A middleware thus runs "around" dispatch and can execute code before or after dispatch executes:

Thunk itself is really simple, as it just checks the type of what was dispatched, if it's a function executes it, otherwise calls next which forwards the dispatched object down the middleware chain.

Redux Middleware executes "around" dispatch and allows running code before, or after original dispatch. Calling next triggers normal dispatch.

redux-promise

Let's continue to redux-promise, another middleware to handle async code but a bit more complex than thunk:

export default function promiseMiddleware({ dispatch }) {
  return next => action => {
    if (!isFSA(action)) {
      return isPromise(action)
        ? action.then(dispatch)
        : next(action);
    }

    return isPromise(action.payload)
      ? action.payload.then(
          result => dispatch({ ...action, payload: result }),
          error => {
            dispatch({ ...action, payload: error, error: true });
            return Promise.reject(error);
          }
        )
      : next(action);
  };
}

Redux promise allows external code to trigger it in multiple ways:

  1. External code calls dispatch(Promise)
  2. External code calls dispatch({ type: '...', payload: Promise })

The first block handles the first use case:

    if (!isFSA(action)) {
      return isPromise(action)
        ? action.then(dispatch)
        : next(action);
    }

For this to work the external code should dispatch something like:

dispatch(Promise.resolve({ type: '@@status', payload: 'DONE' }));

When the middleware detects external code dispatched a promise, it'll call action.then(dispatch) which will wait for the promise to resolve and then call dispatch with the promise resolved value. This use case is similiar to what we saw with redux-thunk.

The second block handles the second use case:

    return isPromise(action.payload)
      ? action.payload.then(
          result => dispatch({ ...action, payload: result }),
          error => {
            dispatch({ ...action, payload: error, error: true });
            return Promise.reject(error);
          }
        )
      : next(action);
  };

In case external code dispatches an action whose payload is a promise, the middleware will dispatch new actions when the promise is resolved based on the resolve value or error.

Notice how middleware code returns the promise itself. This return value will be the return value of dispatch, which makes this code possible:

await dispatch(Promise.resolve({ type: '@@status', payload: 'DONE' }));

Dispatch returns the value returned by the middleware. This is usually the action itself, but we can control it where we see fit.

redux-batch-middleware

The most complex middleware we should review is redux-batch-middleware. Generally every state change in the store triggers render in the UI. Using batches we can reduce the number of render calls in situation where sequences of dispatch are involved. This code for example would normally cause 3 update notifications:

dispatch({ type: 'inc', payload: 1 })
dispatch({ type: 'shuffle' })
dispatch({ type: 'play', payload: [2, 2] })

redux-batch-middleware will allow us to write the code as follows and combine all the above dispatch calls into 1 change in the store:

dispatch([
    { type: 'inc', payload: 1 },
    { type: 'shuffle' },
    { type: 'play', payload: [2, 2] },
]);

Let's look at its source:

export const type = '@@redux-batch-middleware/BATCH';

export const batch = ({ dispatch }) => {
    return (next) => (action) => {
        return Array.isArray(action)
            ? dispatch({ type: type, payload: action })
            : next(action);
    };
};

Here the middleware defines a new type of action. In case an array is dispatched, it will re-dispatch a new action with the invented type. But, who should handle that new action?

Following the same source file we find another function:

export const batching = (reducer) => {
    return function batcher(state, action) {
        return action.type === type
            ? action.payload.reduce(batcher, state)
            : reducer(state, action);
    };
};

This function takes a reducer as its argument and returns a new reducer called batcher. We can tell it's a reducer by its behaviour: taking state and action and returns a new state.

The batcher reducer will normally call the original reducer, unless action of the new batch type is received. In that case it'll call itself with all the actions in the array and return the result.

Batcher reducer is making a smart use of the fact that a reducer is just a function, and so we can "fast forward" multiple actions in a single dispatch call.

Middlewares can work in conjunction with reducers when appropriate. The middleware changes dispatch logic, and the reducers handle the newly dispatched actions.

redux-undo-redo middleware works the same way.

Final Thoughts

Redux middlewares provide a simple way to enhance your application and re-structure your code. They allow you to run code before, after or instead of normal dispatch. Redux middlewares are also amazingly simple, as one only needs to implement a single function, usually no longer than few dozens lines.

Let's finish with a dummy middleware template for you to start with. Go build cool stuff:

function noopMiddleware({ dispatch, getState }){
  return function(next){
    return function(action){
      // code to run BEFORE action is dispatched
      const res = next(action);

      // code to run AFTER action is dispatched
      // or alter return value
      return res;
    }
  }
}

Comments