DEV Community

loading...

Redux middleware as pure functions

pigozzifr profile image Francesco Pigozzi Updated on ・5 min read

Source of inspiration

I decided to write this article after seeing this video by Nir Kaufman. Do not be scared by the title, it's within reach of any developer with a minimum of imagination and sense of humor.

What is a Redux middleware?

A function. Really, nothing more.

Actually, it's a function that returns a function that returns a function that does something.

Something like this:

const middleware = () => () => () => {
  // Do something
}

It looks a lot like a normal Javascript's closure, isn't it?

Let's review it populated by some parameters:

const middleware = (store) => (next) => (action) => {
  next(action)
}

Let's analyze the parameters that are passed to it:

  • store: the actual store of Redux, from which we can deconstruct getState and dispatch
  • next: the next middleware
  • action: the action that has been dispatched

It is necessary to call next(action), otherwise the flow will be blocked (this does not have to be a negative thing).

Pros and cons about using one or more custom middleware

Pros

  • Freedom of implementation
  • No pattern constraints, just a few suggestions
  • No bottlenecks hidden somewhere, just pure functions

Cons

  • Freedom of implementation
  • No pattern constraints, just a few suggestions
  • No bottlenecks hidden somewhere, just pure functions

No, you didn't become crazy: I deliberately returned the same points. This free approach is very powerful but very dangerous if not used in the right way: you could find yourself managing performance drops only due to a poor implementation or management of a side-effect.

The classic scene where the developer plays both the sheriff and bandit roles.

Remember the words of Uncle Ben:

Why should I build one or more middleware, then?

Well, you don't really have to.

The alternatives are varied and vast: just think of redux-thunk, redux-saga and many others. They are all middleware in turn, do their work and some of them are also very performing.

That said, if you still think that you want to use a library, I will not stop you. Actually, I'm a big fan of redux-saga!

Just a few suggestions

Let's now see together, referencing to Nir's suggestions, some patterns that can be used immediately.

Filter

const middleware = (store) => (next) => (action) => {
  // Avery action with type BAD_ACTION will be removed from the flow
  if (action.type === 'BAD_ACTION') return

  next(action)
}

Map

const middleware = ({ dispatch }) => (next) => (action) => {
  // We don't want to remove this action from the flow
  next(action)

  if (action.type === 'ACTION_FROM') {
    // Instead, we want to fire a side-effect
    dispatch({ type: 'ACTION_TO' })
  }
}

Split

const middleware = ({ dispatch }) => (next) => (action) => {
  // We don't want to remove this action from the flow
  next(action)

  if (action.type === 'ACTION_COMPOSED') {
    dispatch({ type: 'ACTION_FIRST' })
    dispatch({ type: 'ACTION_SECOND' })
  }
}

Compose / Aggregate

Compose and Aggregate are similar in their behavior.

To differentiate them we could simply say that the first logic expects more actions of the same type and then generates a unique side-effect, similar to a buffer; the second logic expects actions of different types.

To achieve this, we need to introduce the concept of middleware status. Thus creating what I like to call stateful-middleware.

Let's see how to implement this pattern in order to generate a side-effect, without blocking the flow.

// We are not defining a middleware, we are defining a middleware's factory
const makeMiddleware = (waitFor, waitTimes, handler) => {
  let actions = []

  // Here we are returning a middleware
  return (store) => (next) => (action) => {
    next(action) // We aren't blocking the flow

    if (action.type === waitFor) {
      actions.push(action)

      if (actions.length === waitTimes) {
        // We then pass to the handler all the actions
        handler(store, actions)

        // We may then reset the "state" to start again
        actions = []
      }
    }
  }
}

Or using a blocking approach.

// We are not defining a middleware, we are defining a middleware's factory
const makeMiddleware = (waitFor, waitTimes, handler) => {
  let actions = []

  // Here we are returning a middleware
  return (store) => (next) => (action) => {
    if (action.type === waitFor) {
      actions.push(action)

      if (actions.length === waitTimes) {
        // We then pass to the handler all the actions blocked before
        handler(store, actions)

        // We may then reset the "state" to start again
        actions = []
      }
    } else {
      next(action)// We are blocking the flow
    }
  }
}

Deciding to block or not the flow is up to you, both cases could be useful to solve different problems.

To go from Compose to Aggregate it will be enough to allow the factory to verify that the action that it's been intercepted is among those that should be waited for.

// snip
// Where waitFor is an array of action types like ['ACTION_A', 'ACTION_B', 'ACTION_C']
// Boolean is unecessary, just for clarity
if (Boolean(~waitFor.indexOf(action.type))) { /* snip */ }
// snip

Enrich

This pattern turned very useful for me for adding, for example, a timestamp to some actions.

const middleware = (store) => (next) => (action) => {
  if (action.type === 'ACTION_TO_ENRICH') {
    next({
      ...action,
      payload: {
        ...action.payload,
        '@': Date.now(),
      }
    })
  } else {
    next(action)
  }
}

Normalize

const middleware = (store) => (next) => (action) => {
  if (action.type === 'ACTION_TO_NORMALIZE') {
    // Clone payload, it will be less painful to modify it
    const payload = { ...action.payload }
    if (typeof payload.postId === 'number') payload.postId = payload.postId.toString()

    next({
      ...action,
      payload,
    })
  } else {
    next(action)
  }
}

Translate

Indeed, I do not think I have a real example for this pattern. If you can think of a better one, please let me know in the comments!

const middleware = (store) => (next) => (action) => {
  if (action.type === 'ACTION_TO_TRANSLATE') {
    next({
      ...action,
      type: 'ACTION_TRANSLATED',
    })
  } else {
    next(action)
  }
}

How to integrate these newly created middleware

I will not go into the merits of how to create a Redux store, you've done it thousands of times. Rather, I will show you how to apply these middleware to the newly created store.

Do not take this example literally, there are many ways to handle more and more middleware within the codebase. This approach is the simplest that came to my mind.

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

import rootReducer from './rootReducer'
import initialState from './initialState'
import { someFilterMiddleware, someMapMiddleware, someComposeMiddleware } from './middlewares'

const customMiddlewares = [
  someFilterMiddleware,
  someMapMiddleware,
  someComposeMiddleware('ACTION_TO_WAIT', 2, (store, actions) => console.log(actions))
]

const configureStore = () => {
  // Spread them as arguments for applyMiddleware
  const middlewares = applyMiddleware(...customMiddlewares)

  const store = createStore(
    rootReducer,
    initialState,
    compose(middlewares),
  )

  return store
}

export default configureStore

Attention!

All of this is very nice, but the fact remains that, the more middleware created, the more the number of functions through which an action must pass before reaching its destination increases. Rather, you might prefer a mono-middleware approach that can handle a series of major cases if, and only if, some criteria are met: something similar to redux-saga.

redux-saga is structured in a single middleware, which runs a generator-runner over and over, as long as there are actions to be interpreted or effects to be dispatched. We will not go further.

What's the point?

The point is that you do not really need a particularly complex or engineered library to manage application side-effects or business logic in a modular way.

Do you need to manage a login? Create a middleware. Don't you need it anymore? Disconnect the middleware from the flow and you won't have to act elsewhere.

There is no more versatile thing than being able to use any pattern, conventions or browser API to achieve a goal.

You can use closures, factory, iterator (why not), setTimeout, setInterval or the newest requestIdleCallback API.

Again, I am not saying that this approach can completely replace a single and more structured middleware. After all, if certain libraries were born, they had excellent reasons. I just wanted to share with you a way to handle some logic that was different from the usual.

Thanks everybody!

Thank you for reading this article until the end! If you liked it, leave a 🦄!

If you do not agree with what I wrote, leave a comment and share some ideas!

Discussion (7)

pic
Editor guide
Collapse
olegchursin profile image
Oleg Chursin • Edited

Super cool, mate. Although we are currently working our way out of Redux boilerplate with the help of GraphQL and hooks.

Collapse
pigozzifr profile image
Francesco Pigozzi Author

Do you think that, maybe one day, all the Redux ecosystem will be replaced by something like GraphQL and hooks?

Collapse
olegchursin profile image
Oleg Chursin

Definitely. Redux is awesome and it's not going anywhere anytime soon. But the amount of code it takes to write action/reducer pairs is something. If you add types (as in TypeScript based apps) and tests it waterfalls to insanity.

Thread Thread
pigozzifr profile image
Francesco Pigozzi Author

I actually like to write all the stuffs needed, it gives me freedom to choose whatever pattern I want. But for larger apps I agree with you. Do you have some good resources to learn the GraphQL way?

Thread Thread
olegchursin profile image
Oleg Chursin

Actually, found this one recently: dev.to/marionschleifer/beginners-g...

Collapse
johnkievlan profile image
johnkievlan

Mmm I can't help pointing out that these aren't pure functions. A pure function takes a set of parameters and produces the same result every time for the same set of parameters, without performing any side effects. Just because a function does something vaguely analogous to the "map" concept that is generally used as a pure function example does not mean that it is a pure function.

Also, to use your "map" example again -- that isn't actually even analogous to map. Much closer:

const middleware = (store) => (next) => (action) => {
  if (action.type === 'ACTION_FROM') {
    // We want to map this to a different action
    next({ ...action, type: 'ACTION_TO' })
  }
  else
  {
    next(action);
  }
}
Collapse
pigozzifr profile image
Francesco Pigozzi Author

Hi @johnkievlan , thanks for your feedback!

Well, you're right, these aren't pure functions for real.

This post is intended to be a point of view and a source of inspiration, not an over-engineered one.

And thanks for the better example, it may be useful for further readers!