Redux Middleware Explained

How Does Redux Middleware Actually Work?

Whether you're looking to add some customization to your Redux workflow or just looking to understand how Redux works a little better, it's great to know exactly what Redux is doing under the hood with middleware.

I've been going down this rabbit hole trying to write my own Redux middleware so I thought I'd share my findings.

Fortunately for us, Redux turns out to be fairly simple, so let's take a closer look at the Redux source code and try to figure out what's going on.

I'll pull examples from the Redux source code and type definitions, but I'll be stripping it down for simplicity, mostly removing some error-handling and subscription management code (subscriptions aren't particularly relevant for us here). Let's start with the primitives and work our way up.

Examples are based on Redux 4.0.4

What is an Action?

export interface Action<T = any> {
  type: T
}

Most strictly, an Action is an object with a field type. More usefully, an Action describes what change should be made to the application state.

What is a Reducer?

export type Reducer<S = any, A extends Action = AnyAction> = (
  state: S | undefined,
  action: A
) => S

A Reducer is a function that, given an existing state and an Action, produces the state that should exist after the Action has been applied. If an Action describes what changes should be made, a Reducer describes how that change should be made.

What is a Store?

Very simply, it's just an object that provides us a way to get the current application state (getState()) and provides a dispatch function that allows us to send Actions to our Reducers. A simplified interface would be something like this:

interface Store<S = any, A extends Action = AnyAction> {
  dispatch: Dispatch<A>
  getState(): S
}

We create our Redux store using the createStore function, which, well, creates our Store. More importantly, it contains some important variables that will get used by functions like dispatch.

function createStore(reducer, preloadedState, enhancer) {
  ...
  let currentReducer = reducer
  let currentState = preloadedState
  let currentListeners = []
  let nextListeners = currentListeners
  let isDispatching = false
  ...
}

What's important is that dispatch and other functions are also created in createStore, meaning that these variables are closed over, i.e. dispatch will have access to these variables, and we'll see how it uses them next.

What does dispatch actually do?

I'll be honest, I was a little surprised at just how simple the dispatch function is when I first saw it (though I don't really know what I expected). You'll see that is uses those variables defined in createStore from before. Here's a stripped down version, showing just the meat of it:

function dispatch(action) {
  try {
    isDispatching = true
    currentState = currentReducer(currentState, action)
  } finally {
    isDispatching = false
  }

  return action

Well, that's about it. It reassigns currentState to the new application state the reducer spit out in response to an Action.

Keep in mind, there is only one Reducer – don't let the variable name currentReducer throw you off. It's only "current", because of some functionality Redux provides for dynamically loading reducers that isn't relevant for us here. Speaking of which, how does Redux make all our different reducers act as one?

How does Redux combine reducers?

Redux provides the combineReducers function, which takes a object-map of sub-reducers and returns a single function, i.e. a Reducer, that invokes all of them. Here's a simplified version of combineReducers.

function combineReducers(reducers) {
  return (state = {}, action) => {
    let hasChanged = false
    const nextState = {}

    for (const reducerKey in reducers) {
      const reducer = reducers[reducerKey]
      const previousStateForKey = state[reducerKey]
      const nextStateForKey = reducer(previousStateForKey, action)
      nextState[key] = nextStateForKey
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    return hasChanged ? nextState : state
  }
}

Again, just like dispatch, this is actually pretty simple. We go through all the sub-reducers we have, pass it existing state and the action, and collect all those results into a new state object.

What does a Redux Middleware look like?

Middleware is where this gets a little more complicated (which is why it was important to go over the building blocks!).

Middleware allows us to add all sorts of functionality to Redux between the points when an action is first dispatched to when it's sent to the reducer. It also allows us to enhance the dispatch function to add to its capabilities.

Let's examine all the relevant code.

MiddlewareAPI

export interface MiddlewareAPI<D extends Dispatch = Dispatch, S = any> {
  dispatch: D
  getState(): S
}

A MiddlewareAPI is what gives our middleware access to dispatch and a way to get the current state. Simple enough.

Middleware

export interface Middleware<
  DispatchExt = {},
  S = any,
  D extends Dispatch = Dispatch
> {
  (api: MiddlewareAPI<D, S>): (
    next: Dispatch<AnyAction>
  ) => (action: any) => any
}

This type definition might be a little hard to follow, but notice that the last function in the signature just takes in an action, i.e. the same as dispatch.

I think the best way to think about it is that every middleware is actually just a dispatch that has a handle to the next dispatch in the chain. So really, our middlewares are just custom dispatchs that handle the action in some way and then pass the action off to the next dispatch. You may need to stew on that for a minute and make sure you understand it.

The reason this signature needs to be curried is that the custom dispatch we create needs access to the MiddlewareAPI and a handle to the next middleware/dispatch in our middleware chain. All that happens in applyMiddleware.

applyMiddleware

export function applyMiddleware(...middlewares: Array<Middleware>): StoreEnhancer

Basic type definition.

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

I'm assuming you have a baseline understanding of curried functions in JavaScript here I think a helpful way to conceptualize deeply curried functions is as just a single function with multiple 'layers' of parameters. The 'layers' get 'peeled off' when you apply a argument to it, which allows us to share common data between multiple curried functions. In this way, a Middleware is a curried function with three layers – an APIMiddleware, a Dispatch, and finally an Action. applyMiddleware 'peels off' two layers of our Middlewares, creating a single function that becomes our Redux Store's dispatch.

To 'peel off' the first layer, we first create a MiddlewareAPI from our newly created store and give all our middlewares access to it with middlewares.map(middleware => middleware(middlewareAPI)).

To 'peel off' the second layer, we chain our Middlewares together with the compose function. compose serially chains multiple functions together into a single function. It's actually not terribly complicated, but it's easier to understand by example: compose(F, G, H) turns into (...args) => F(G(H(...args))). (Note that in this chain, the parameters that H accepts become the parameters that our newly created chain accepts.) Finally, we call this composed Middleware chain with the default dispatch from our store.

With the second layer peeled off, now our middlewares are just normal dispatchs (Action => any) that have a handle to next i.e. the next dispatch in the chain. (That means we have to make sure our Middleware calls next at some point, or the dispatch won't make it to the end i.e. our Reducer!)

I'll try my best to illustrate that process the best I can.

const chain = middlewares.map(middleware => middleware(middlewareAPI))
const composedChain = compose(...chain)
dispatch = composedChain(store.dispatch)

Calling our composed chain with store.dispatch will make store.dispatch the final dispatch in the chain. Here's some pseudocode that outlines how that application would go.

H(store.dispatch):
  (action: Action) => {
    // custom stuff...
    store.dispatch(action)
  }
    G(H):
      (action: Action) => {
        // custom stuff...
        H(action)
      }
        F(G):
          (action: Action) => { // Our new Dispatch!
            // custom stuff...
            G(action)
          }

And now here's what our final dispatch will look like. This is, again, pseudocode to illustrate how the data flows, not the definitions of the functions.

(action: Action) => {
  F(action) ->
    // custom stuff
    G(action) ->
      // custom stuff
      H(action) ->
        // custom stuff
        store.dispatch(action)
}

The final bit of applyMiddleware is returning our store, but with dispatch overridden to be our custom dispatch.

return {
  ...store,
  dispatch
}

And finally, we've created our Redux store with our middleware applied!

Try It Out

I hope this look into Redux has given you what you need to start tinkering yourself. I did most of this research while figuring out how to create a custom middleware of my own. It's certainly within reach to try it yourself – maybe a logging framework, maybe a network call abstraction layer, maybe something even wilder. Go ahead and tinker with it and see what you can come up with.

Last updated