Redux Middleware - Comparing Epics, Thunks, and Sagas

5 years ago

Summary

One of the best things about Redux is the flexibility it provides through middleware. Redux middleware performs it's work between when an action is dispatched and the moment it reaches the reducer. It is useful when dealing with asynchronous events like network requests or logging. In this respect, you can find tons of feature specific middleware out there. On the other hand, some middleware focuses on coding patterns which may help you adopt a more consistent coding style.

In this example, I have created a simple todos app, that is loosely based on the one in Redux's documentation. I have made changes to the file structure slightly, added constants, some error handling, and light styling with Bootstrap. However, the focus of this post will be on three popular middleware:

  • redux-observable - RxJS middleware for action side effects in Redux using "Epics" (Co-developed by Jay Phelps and Ben Lesh at Netflix.)
  • redux-saga - An alternative side effect model for Redux apps. The coding style is based on JavaScript's Generator syntax. (As of date, it has the most contributors, stars, and forks on Github.)
  • redux-thunk - implements Promise patterns. (Co-authored by Redux's originator and Facebook developer, the legendary Dan Abramov.)

All of these have excellent documentation, so I won't go into every detail of how they are used. Instead I'd like to focus on some of the high-level details around how they are composed in the store, the differences in how you create them, and finally how they get called. Based on the latest npm trends, all are on the upswing other than a few seasonal dips.

Note To keep this simple, I have put my action creators, constants, and store all in a config folder. I have also included all middleware in the reducers folder.


Screenshot


Todo Epic (reducers/TodoEpic.js)

What sets redux-observable apart from the other two is that it is meant to be used with RxJS. RxJS uses a declarative coding pattern and is based on data streams and propagation of change. This means it works exceptionally well for things like AJAX, auto-complete, drag and drop, form debouncing, and web sockets, among others. It can also implicitly cancel streams declaratively which makes it more expressive and allows for less boilerplate code.

redux-observable middleware is implemented using an "epic", which is defined as a function that takes a stream of actions and returns a stream of actions. These allow you to chain operators which are especially useful for filtering, tranformations, and projection.

Steps

  • use the action stream (action$) ofType property to listen for type of TODO.GETTODO OBSERVABLE
  • chain to a switchMap operator to return an action of TODO.GET_COMPLETE which include our todos.
  • TODO.GET_COMPLETE action is then handled by a separate Redux reducer to update our state.
import 'rxjs/add/observable/of'
import 'rxjs/add/operator/switchMap'
import { Observable } from 'rxjs/Observable'

import { TODO } from '../config/constants'
import { todosEpic } from './../data' // Array of objects with id, text, and completed

export const todoGetEpic = action$ =>
  action$.ofType(TODO.GET_TODO_OBSERVABLE).switchMap(() =>
    Observable.of({
      type: TODO.GET_COMPLETE,
      todos: todosEpic
    })
  )

Note

  • You could import the entire RxJS library in your index.js, but for a smaller footprint for performance and bundling, you should be in the habit of importing operators only as needed.
  • action$ is a common naming convention for action streams. I personally prefer spelling variable names out more explicitly, but am following the accepted convention here.

Todo Saga (reducers/TodoSaga.js)

redux-saga is used in conjunction with the JavaScript Generator feature which provides a new syntax for creating iterables and has some very nice patterns for workflow and asynchronous code.

Steps

  • our entry point is an exported generator called todoSaga which uses a declarative effect called takeEvery to listen for the TODO.GET_TODO_SAGA and map it to our todoGetSaga generator function.
  • todoGetSaga yields and Effect description using the put effect using an action of TODO.GET_COMPLETE which include our todos.
  • As above, a Redux reducer handles TODO.GET_COMPLETE
import { put, takeEvery } from 'redux-saga/effects'

import { TODO } from '../config/constants'
import { todosSaga } from './../data' // Array of objects with id, text, and completed

function* todoGetSaga() {
  yield put({ type: TODO.GET_COMPLETE, todos: todosSaga })
}

// Entry Point
export function* todoSaga() {
  yield takeEvery(TODO.GET_TODO_SAGA, todoGetSaga)
}

Todo Thunk (reducers/TodoThunk.js)

redux-thunk implement thunks that simply uses the concept of currying which involves functions returning functions, which can then be called. (redux-curry would have been such a great name) 😀 It works very well and is widely used with JavaScript promises in mind.

Steps

  • Our entry point is todoGetThunk which returns a function
    • that function returns a function with a promise
      • which resolves our todos and dispatches and action of TODO.GET_COMPLETE which include our todos
  • Like the last two, a Redux reducer handles TODO.GET_COMPLETE
import { TODO } from '../config/constants'
import { todosThunk } from './../data' // Array of objects with id, text, and completed

let getTodos = () => {
  return new Promise((resolve, reject) => {
    resolve(todosThunk)
  })
}

// Entry Point
export const todoGetThunk = () => {
  return dispatch => {
    return getTodos().then(todos => dispatch({ type: TODO.GET_COMPLETE, todos }))
  }
}

A Digression: You can always adjust how terse your code is. Just remember to be mindful of readability and debugging.

More Terse (using arrow function concise body syntax)

export const todoGetThunk = () => dispatch => getTodos().then(todos => dispatch({ type: TODO.GET_COMPLETE, todos }))

Less Terse

function dispatchTodo(dispatch, todos) {
  return dispatch({ type: TODO.GET_COMPLETE, todos })
}

function dispatchInitTodo(dispatch) {
  return getTodos().then(todos => {
    return dispatchTodo(dispatch, todos)
  })
}

export function todoGetThunk() {
  return dispatchInitTodo
}

index.js (reducers\index.js)

Using an index is an easy way to combine all our reducers, epics, and sagas, which allows us to have a single access point for our store. We could include this all in our store, but I prefer separating them out. Thunks are not included here as they are called independently. redux-observable provides a combineReducers function and we also use redux's combineReducers function.

import { combineEpics } from 'redux-observable'
import { combineReducers } from 'redux'

import { todoGetEpic } from './TodoEpic'
import { todoSaga } from './TodoSaga'
import todo from './TodoReducer'
import visibilityFilter from './VisibilityReducer'

const epics = combineEpics(todoGetEpic)

const reducers = combineReducers({
  todo,
  visibilityFilter
})

const sagas = [todoSaga]

export { epics, reducers, sagas }

Store (config/store.js)

In order integrate epics and sagas, you will need to create them first, so they can be applied here in the store. Since the individual thunks are called independently, we only need to apply the thunk middleware here. There are a few other differences on how each respective middleware is wired up here which I have outlined below.

Steps

  • epic (redux-observable)
    • createEpicMiddleware with your epics
    • pass the middleware into applyMiddleware
  • saga
    • create with createSagaMiddleware
    • pass the middleware into applyMiddleware
    • call sagaMiddleware.run with your sagas.
  • thunk
    • pass the middleware into applyMiddleware
import { applyMiddleware, compose, createStore } from 'redux'
import { createEpicMiddleware } from 'redux-observable'
import createSagaMiddleware from 'redux-saga'
import thunk from 'redux-thunk'

import { epics, reducers, sagas } from '../reducers'

const epicMiddleware = createEpicMiddleware(epics)
const sagaMiddleware = createSagaMiddleware()
const storeEnhancer = applyMiddleware(epicMiddleware, sagaMiddleware, thunk)
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const enhancer = composeEnhancers(storeEnhancer)
const store = createStore(reducers, enhancer)

sagaMiddleware.run(...sagas)

export default store

Side Note: I tend to write fairly terse code, but with the store I really like breaking it down more explicitly. Finding the right balance between readability and conciseness ever the challenge.


MiddlewareSelector.js (containers/MiddlewareSelector.js)

The final steps involve creating a higher-order component using Redux's connect function.

import { connect } from 'react-redux'

import { MIDDLEWARE, TODO } from '../config/constants'
import MiddlewareOptions from '../components/MiddlewareOptions'
import { todos } from '../data'
import { todoGetThunk } from '../reducers/TodoThunk'

const getTodos = (dispatch, middleware) => {
  switch (middleware) {
    case MIDDLEWARE.NONE:
      dispatch({ type: TODO.GET_COMPLETE, todos })
      break
    case MIDDLEWARE.OBSERVABLE:
      dispatch({ type: TODO.GET_TODO_OBSERVABLE })
      break
    case MIDDLEWARE.SAGA:
      dispatch({ type: TODO.GET_TODO_SAGA })
      break
    case MIDDLEWARE.THUNK:
      dispatch(todoGetThunk())
      break
    default:
      break
  }
  dispatch({ type: TODO.UPDATE_MIDDLEWARE, middleware })
}

const mapStateToProps = (state, ownProps) => ({
  middleware: state.todo.middleware || MIDDLEWARE.NONE,
  middlewareList: MIDDLEWARE
})

const mapDispatchToProps = (dispatch, ownProps) => ({
  updateMiddleware(middleware) {
    getTodos(dispatch, middleware)
  }
})

const MiddlewareSelector = connect(
  mapStateToProps,
  mapDispatchToProps
)(MiddlewareOptions)

export default MiddlewareSelector

MiddlewareOptions.js (components/MiddlewareOptions.js)

This almost purely a presentation component, with the exception of componentDidMount to wire up the initial data. Alternatively, you could wire this up in App.js or create an additional container component.

import React, { Component } from 'react'

class MiddlewareOptions extends Component {
  componentDidMount() {
    this.props.updateMiddleware(this.props.middleware)
  }

  render() {
    return (
      <div>
        <hr />
        <h4>Middleware Option</h4>
        <div className="custom-controls-stacked">
          {Object.keys(this.props.middlewareList).map(prop => {
            const value = this.props.middlewareList[prop]
            return (
              <label key={prop} className="custom-control custom-radio">
                <input
                  name="middleware"
                  type="radio"
                  className="custom-control-input"
                  value={value}
                  onChange={e => this.props.updateMiddleware(e.target.value)}
                  checked={this.props.middleware === value}
                />
                <span className="custom-control-indicator"></span>
                <span className="custom-control-description">{value}</span>
              </label>
            )
          })}
        </div>
      </div>
    )
  }
}

export default MiddlewareOptions

Conclusion

Hopefully this give you a good starting point to using redux-observable, redux-saga, and redux-thunk in your own projects. They provide some excellent patterns and abstraction for managing state in your applications. You can get more details by checking out the source code or live link.


Note: There is no persistence layer set up, so todos will be the same when refreshing or changing middleware.


Discuss on Twitter