thumb
April 07,2021 Anh Ben

Why should we use a middleware like Redux Thunk

Have you ever wondered why you have to use a middleware like Redux Thunk , Redux Saga ? Or do you just use it because you see tutorials online telling you to use 😜 and that's it.

Anyway, it's time for you to reconsider whether we really need a middleware or not, in this article I will analyze between writing pure no middleware and using Redux Thunk.

1. Asynchronous Dispatch

For example, a user logs in to our website, after successful login, a toast message will be displayed, after 5 seconds, that message will automatically hide.

This is the simplest way to do it in Redux


  store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
  setTimeout(() => {
    store.dispatch({ type: 'HIDE_NOTIFICATION' })
  }, 5000)
  

Or like this inside connected component


  this.props.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
  setTimeout(() => {
    this.props.dispatch({ type: 'HIDE_NOTIFICATION' })
  }, 5000)
  

The only difference is that inside a connected component, usually you won't access the store directly, you'll get dispatch via prop (or hook for React Hooks). However, there is no significant difference.

If you don't want to retype when dispatch with the same actions from different components, you can separate the action like this instead of having to dispatch an object


  // actions.js
  export function showNotification(text) {
    return { type: 'SHOW_NOTIFICATION', text }
  }
  export function hideNotification() {
    return { type: 'HIDE_NOTIFICATION' }
  }
  // component.js
  import { showNotification, hideNotification } from '../actions'
  this.props.dispatch(showNotification('You just logged in.'))
  setTimeout(() => {
    this.props.dispatch(hideNotification())
  }, 5000)
  

Or if you put actions inside connect() you would use it like this


  this.props.showNotification('You just logged in.')
  setTimeout(() => {
    this.props.hideNotification()
  }, 5000)
  

So far we haven't used any middleware or advanced concept.

2. Split into asynchronous action action

The above approach works well in simple cases, but you may find a few problems:

  • It makes you rewrite this logic anywhere you want to show the message.
  • Notifications without ID to distinguish each other, easily leading to the phenomenon of dispatch HIDE_NOTIFICATION 1 is that all existing messages on the screen are hidden earlier than expected.

To solve these problems, we need to split into a function that only focuses on timeout logic and dispatching 2 actions. It might look like this:


  // actions.js
  function showNotification(id, text) {
    return { type: 'SHOW_NOTIFICATION', id, text }
  }
  function hideNotification(id) {
    return { type: 'HIDE_NOTIFICATION', id }
  }
  let nextNotificationId = 0
  export function showNotificationWithTimeout(dispatch, text) {
    // Base on ID to identify Hide or Display
    const id = nextNotificationId++
    dispatch(showNotification(id, text))
    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
  

Components can now use showNotificationWithTimeout without duplicating the above logic or facing the issue of hiding notifications:


  // component.js
  showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
  // otherComponent.js
  showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')
  

Why does showNotificationWithTimeout() have dispatch as the first argument? Because it needs to dispatch actions to the store. Normally a component does the dispatch but since we want an external function to do this, we need to pass dispatch in.

If you have a singleton store exported from some module, you can import it and use dispatch directly like this


  // store.js
  export default createStore(reducer)
  // actions.js
  import store from './store'
  // ...
  let nextNotificationId = 0
  export function showNotificationWithTimeout(text) {
    const id = nextNotificationId++
    store.dispatch(showNotification(id, text))
    setTimeout(() => {
      store.dispatch(hideNotification(id))
    }, 5000)
  }
  // component.js
  showNotificationWithTimeout('You just logged in.')
  // otherComponent.js
  showNotificationWithTimeout('You just logged out.')
  

This looks simpler, but we shouldn't. The main reason is because it forces the store to be a singleton. This makes it difficult to integrate into server rendering. On the server, you'll want each request to have its own store, so that different users receive a different preload data.

A singleton is also harder to test.

So we shouldn't do that, or you're sure in the future the app will be client-side only.

Go back to previous version:


  // actions.js
  // ...
  let nextNotificationId = 0
  export function showNotificationWithTimeout(dispatch, text) {
    const id = nextNotificationId++
    dispatch(showNotification(id, text))
    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
  // component.js
  showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
  // otherComponent.js
  showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')
  

This solved the problem with repeating logic and hiding notifications.

3. Thunk Middleware

For simple apps, the above approach seems fine. You don't need to worry about middleware if you're happy with it.

In bigger apps, you may encounter some inconvenience around it.

For example, it doesn't seem very nice when we have to propagate dispatch all over the place. This makes the separate containers and component become more complicated because any component that dispatch an asynchronous Redux action must receive dispatch as a prop. You can't bind actions to connect() anymore because showNotificationWithTimeout() is not really an action creator anymore. It does not return an object (Redux action).

Also, it's not very nice to have to remember which functions are synchronous actions like showNotification() and which are asynchronous like showNotificationWithTimeout(). Since they are used differently, you must also be careful or you will lead to unnecessary errors.

We need a way to show Redux that the asynchronous action creators are a special case of the action creator instead of as a completely different function.

If you are still here with me and you are also aware of the problem inside your app, welcome to use Redux Thunk middleware.

In the gist, Redux Thunk "taught" Redux to recognize these special actions.


  import { createStore, applyMiddleware } from 'redux'
  import thunk from 'redux-thunk'
  const store = createStore(
    reducer,
    applyMiddleware(thunk)
  )
  // It still recognizes plain object actions
  store.dispatch({ type: 'INCREMENT' })
  // But with thunk middleware, it is also aware of functions
  store.dispatch(function (dispatch) {
    // ... can dispatch many times inside
    dispatch({ type: 'INCREMENT' })
    dispatch({ type: 'INCREMENT' })
    dispatch({ type: 'INCREMENT' })
    setTimeout(() => {
      // ... even asynchronous!
      dispatch({ type: 'DECREMENT' })
    }, 1000)
  })
  

When this middleware is enabled, if you dispatch a function, the Redux Thunk middleware will give that function a dispatch argument. Redux Thunk also helps your reducer to only accept plain object actions.

Redux Thunk also allows us to declare showNotificationWithTimeout() as a regular Redux action creator.


  // actions.js
  function showNotification(id, text) {
    return { type: 'SHOW_NOTIFICATION', id, text }
  }
  function hideNotification(id) {
    return { type: 'HIDE_NOTIFICATION', id }
  }
  let nextNotificationId = 0
  export function showNotificationWithTimeout(text) {
    return function (dispatch) {
      const id = nextNotificationId++
      dispatch(showNotification(id, text))
      setTimeout(() => {
        dispatch(hideNotification(id))
      }, 5000)
    }
  }
  

Note that the spelling is almost the same as the previous version. However, it does not accept send as the first argument. Instead, it returns a function as the dispatch argument.

How do we use it in the component? Obviously, we could write like this:


  // component.js
  showNotificationWithTimeout('You just logged in.')(this.props.dispatch)
  

We are using as a currying function and pass dispatch in.

Looks like it's more "dumb" than the previous version.

But like I said before. If Redux Thunk middleware is enabled, whenever you dispatch a function instead of an object, the middleware will call that function with dispatch is passed as the first argument.

So we can do it like this


  // component.js
  this.props.dispatch(showNotificationWithTimeout('You just logged in.'))
  

Finally, dispatch an async action looks no different from a sync action. This is a good thing because the component doesn't care what happens inside the action, whether it's synchronous or asynchronous.

If combined with connect(), the way we dispatch will be even more concise.


  // actions.js
  function showNotification(id, text) {
    return { type: 'SHOW_NOTIFICATION', id, text }
  }
  function hideNotification(id) {
    return { type: 'HIDE_NOTIFICATION', id }
  }
  let nextNotificationId = 0
  export function showNotificationWithTimeout(text) {
    return function (dispatch) {
      const id = nextNotificationId++
      dispatch(showNotification(id, text))
      setTimeout(() => {
        dispatch(hideNotification(id))
      }, 5000)
    }
  }
  // component.js
  import { connect } from 'react-redux'
  // ...
  this.props.showNotificationWithTimeout('You just logged in.')
  // ...
  export default connect(
    mapStateToProps,
    { showNotificationWithTimeout }
  )(MyComponent)
  

4. Read state in Thunk

In case you want to get the current state of the Redux store, you can pass getState as the second argument to the function you return from the thunk action creator. This allows thunk to read the current state of the store.


  let nextNotificationId = 0
  export function showNotificationWithTimeout(text) {
    return function (dispatch, getState) {
      // Redux doesn't care what you return in thunk
      if (!getState().areNotificationsEnabled) {
        return
      }
      const id = nextNotificationId++
      dispatch(showNotification(id, text))
      setTimeout(() => {
        dispatch(hideNotification(id))
      }, 5000)
    }
  }
  

5.Return in Thunk

Redux doesn't care what you return from thunk, but it will give you the value you return from thunk after dispatch is done. That's why you can return a Promise from thunk and wait for it until it succeeds by calling


  dispatch(someThunkReturningPromise()).then(...)
  

6. In Summary

Don't use any middleware from Redux Thunk, Redux Saga if you really don't need them and understand what you're doing.

If your app in the future is extensible and you want to get the benefits that thunk brings like solving the problem of passing dispatch everywhere in the component, then I recommend Use immediately and always be sure. It's very light anyway.

Thanks for reading this far, see you next time

Share: