Understanding Redux Middleware And Writing Custom Ones

If you use Redux, you’ve most likely used redux middleware before through - for example - redux-thunk, redux-promise-middleware, redux-saga or redux-logger. These are important middleware in most apps but yet some of us (including me) take this concept for granted without digging any further.

With that said, I recently had to implement a caching solution in a large application and, after doing some research and poking one of my colleague’s brain (thank you Rec!), decided that implementing a custom middleware was the best approach for this, mainly because:

  • It makes for cleaner code
  • It makes for more maintainable code (think separation of concerns)
  • It groups all the caching logic in one location

In this article, I’d like to explain what a Redux middleware is, and how I implemented a custom middleware.

What is a Middleware?

For backend developers, a Redux middleware is similar to a middleware in Express or in ASP.NET. Although it is not exactly the same thing, it’s similar and represents a good way of thinking of this concept.

In Redux, a middleware is used to intercept dispatched actions before they make it to the reducer. This means that when you call dispatch on an action, the action goes through a (or many) middleware before hitting the reducer - if it even makes it that far, but more on that later.

This is what the Redux middleware flow looks like

You can apply multiple middleware to a Redux store, which means that the action will have to go through all the middleware before making it to the reducer. The order of execution is actually the order in which you pass the middleware to the store. Also, at any point in a middleware, you can chose to stop forwarding the action, which will end the cycle.

For instance, in my caching middleware, I first check if the same action is already in progress. If it is, I cache the latest payload and interrupt the flow by returning out of the middleware. Since I’m not calling next or dispatch, the action flow will not continue.

Why Use a Middleware?

As expressed above, actions go through middleware before getting to the reducers, which gives us a great way of applying logic or filters to all actions. This means that the logic is grouped in one place instead of being spread across reducers, that we can easily identify where to investigate if a bug occurs, and we can swap that code out if we ever need to.

Some use-cases that benefit from using middleware:

  • Logging: every action goes through this middleware, so we can log its type and payload for debugging or tracking purposes.
  • Error tracking: if any asynchronous action returned an error, this middleware can display a notification.
  • Caching: Only call your API for the same action once, and cache the result for future calls.
  • Auth requests: For API calls, apply an authentication token before sending out the request.
  • So much more :)

Writing a Middleware

To define your own middleware, you need to write a function with the following signature:
store => next => action => result

This looks very confusing at first glance - I hear you - so let’s break it down a little bit:

  • store is the Redux store instance that will be passed to your middleware.
  • next is a function that you need to call with an action when you want to continue the flow execution, which means passing the action to the next in line: either the following middleware or a reducer.
  • action is the action that was originally dispatched so that you can access it, apply logic based on the action, and eventually pass it on using next.
  • result is the value used as the result of the dispatch call.

Finally, to apply this middleware to the Redux store, you need to call applyMiddleware when creating the store through createStore(). Here’s a nice example from the official Redux documentation:

import { createStore, combineReducers, applyMiddleware } from 'redux'

let todoApp = combineReducers(reducers)
let store = createStore(
  todoApp,
  // applyMiddleware() tells createStore() how to handle middleware
  applyMiddleware(logger, crashReporter)
)

In the example above, the middleware logger will be called first, followed by the crashReporter middleware since this is the order in which they were passed to applyMiddleware.

The Caching Middleware

As mentioned in this article’s outline, I implemented a caching middleware recently to solve a very specific issue. I know that there are existing caching middleware out there, but I needed something small and specific to the issue at hand, so I wrote a few lines of code instead of using an existing library.

For this issue, I had to make sure that a WYSIWYG editor only called the backend sequentially when saving the content. For instance, if auto-save kicked in while a save was already occurring, I did not want to send the text to the backend until the previous call completed. The same concept also applies if the user hit the Save button multiple times.

Here’s what my middleware looks like:

export default function textUpdatesMiddleware () {
  return store => next => action => {
    if (action.type === UPDATE_TEXT) {
  	// Check if the new text in the payload is different from what we already have in the store
      if (!shouldSaveText(action.payload, store)) return

	  // Are we currently saving?
	  // isUpdatingText is set to `true` in a reducer
	  // A reducer listens to CACHE_TEXT_UPDATE and will store the payload into `pendingTextUpdate`
	  // We only cache the latest content, not all of them
      if (store.getState().isUpdatingText) {
        return store.dispatch({
          type: CACHE_TEXT_UPDATE,
          payload: action.payload
        })
      } else {
  	  // This uses `redux-promise-middleware`
        return store.dispatch({
  		type: UPDATE_TEXT,
		  payload: {
      	  promise: http.patch(apiEndpoint, content)
    	  }
  	  })
      }
    }
	// This uses the `redux-promise-middleware` convention of _PENDING, _FULFILLED, _REJECTED
    if (action.type === UPDATE_TEXT_FULFILLED) {
      const pendingTextUpdate = store.getState().pendingTextUpdate
      // If we had a pending update
      if (pendingTextUpdate) {
		// A reducer listens to UNCACHE_TEXT_UPDATE and will clear `pendingTextUpdate`
        store.dispatch({ type: UNCACHE_TEXT_UPDATE })
		// Allow the fulfilled action to continue on to the reducers
        next(action)
        // Dispatch the update with the cached content
		return store.dispatch({
          type: UPDATE_TEXT,
          payload: pendingTextUpdate
        })
      }
    }
    // Nothing to do here - keep calm and carry on
	next(action)
  }
}

Based on the code above, it’s worth noting that Redux applies some magic when you call store.dispatch from within a middleware and the action will travel through all the middleware again, including the current middleware that dispatched it. However, when you call next, the action moves on to the next middleware in the flow.

Conclusion

This middleware solves a specific issue I was experiencing, but we could just as well make it more generic so that it applies the same concept to all (or a subset of) actions. At this point, I don’t have any need to make it generic so I did not want to over-engineer it, but it’s worth noting that it’s definitely doable.

If I hadn’t applied this logic in a middleware, I would have had to validate that an API call is not currently in-progress from a reducer, then dispatch calls from the reducer to cache the content, and also listen for the FULFILLED action from the reducer or the then on the http call, and then re-dispatch the action. This gets messy real quick and doesn’t scale well if we need to make it more generic.

I hope this was a good introduction to middleware and that it covered enough of the basics to get you started if you ever need to write your own custom one.

My final piece of advice is that research and discussions are very valuable. I am very glad I decided to not go with my original (bad) approach because something felt wrong and that I did more research, discussed it with a colleague, and ended up settling on using a middleware because the final result is a better solution.

Credit

Wissam Abirached

Wissam Abirached