Redux Middleware Comparison: Thunk, Sagas, Observable & Loop

Redux is probably by far the most popular library to handle application state in React-apps, and while there are other solutions like MobX out there, we at Sandstorm currently fully rely on Redux. Vanilla Redux alone is great to manage state, but does not have a built in way to handle asynchronous operations like AJAX-Requests. There are quite a few very different middlewares out there to help with these tasks and their internal concepts vary quite a bit. 

This blog post is supposed to shed some light on some of the more popular middlewares for async handling - namely Redux-Thunk, Redux-Saga, Redux-Observable and Redux-Loop -  and the basic concepts behind them. I am also going to ramble a bit about when their usage might be appropriate and about their respective pros and cons.

While I am going to showcase each library with a basic example, I will keep it fairly high level and wont go into detail about advanced operations, like cancellation or throttling (these might be the subject of a follow up blog post in the future). Therefore I am going to show how to fetch some sample data with each middleware. I am not going to depict the process of integrating each middleware but rather focus on the way each middleware works. While the examples are fairly basic I assume that you already have a good grasp of how Redux itself works - otherwise I suggest familiarizing yourself with its concepts before continuing to read this blog post.

Table of Contents

  1. Redux-Thunk
  2. Redux-Observable
  3. Redux-Saga
  4. Redux-Loop
  5. Conclusion

Redux-Thunk

Redux-Thunk is usually the first library that is suggested when it comes to basic async handling and is even promoted as the standard way by Redux itself. Its concept is pretty straight forward: while Redux only allows you to dispatch plain action objects, Redux-Thunk also allows you to dispatch functions. This enables us to encapsulate additional logic as well as asynchronous operations and respond by dispatching different actions.

Example:

// ACTION TYPES ------------------------------------------------------------- const FETCH_USERS_START = "FETCH_USERS_START"; const FETCH_USERS_SUCCESS = "FETCH_USERS_SUCCESS"; const FETCH_USERS_FAILURE = "FETCH_USERS_FAILURE"; export const actionTypes = { FETCH_USERS_START, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE, } // SYNCHRONOUS ACTIONCREATORS // (we use the "redux-actions"-library for easier creation of basic actionCreators) const syncActionCreators = createActions({ FETCH_USERS_START : () => {}, FETCH_USERS_SUCCESS: users => ( { users } ), FETCH_USERS_FAILURE: errorMessage => ( { errorMessage } ) }) // ASYNC ACTIONCREATORS const asyncActionCreators = { fetchUsers: () => { // We return a function which will get our store.dispatch handed by the thunk middleware return (dispatch) => { // We manually dispatch our START action to let the app know we're loading user data dispatch(syncActionCreators.fetchUsersStart()) // Here we make our actual AJAX-request and depending on if the promise // resolves/rejects we dispatch our success or error action respectively return fetch("https://jsonplaceholder.typicode.com/users") .then(response => { if (response.ok) { return response.json() } else { throw new Error('damn :(') } }) .then(data => dispatch(syncActionCreators.fetchUsersSuccess(data))) .catch(error => dispatch(syncActionCreators.fetchUsersFailure(error))) } } } // ACTIONS ------------------------------------------------------------- export const actions = { ...syncActionCreators, ...asyncActionCreators }; // REDUCERS ------------------------------------------------------------- const usersReducer = handleActions({ [FETCH_USERS_START]: (state, action) => Object.assign({}, state, { fetching: true }), [FETCH_USERS_SUCCESS]: (state, action) => Object.assign({}, state, { fetching: false, items: action.payload.users }), [FETCH_USERS_FAILURE]: (state, action) => Object.assign({}, state, { fetching: false, errorMessage: action.payload.errorMessage }) }, { items: [], fetching: false, errorMessage: '' })

Whenever our fetchUsers()-function is dispatched, its inner function will receive the stores dispatch method. At this point no actual action has been dispatched and our state remains unchanged. Before we attempt our API-request using the fetch-API we therefore should dispatch our fetchUsersStart-action.

This will tell our usersReducer to set the fetching-state to true and we could now for example show a loading spinner inside our user interface. Then we make our actual request, which eventually leaves us with a promise that we can handle like usual. If the promise resolves we will dispatch our fetchUsersSuccess-action. If it rejects, we dispatch the fetchUsersFailure-action. - simple, right?

Pros and Cons of Redux-Thunk

Redux-Thunk is really easy to learn as there are literally no new concepts involved at all. Everything is basic javascript and therefore makes it easy to pick up and integrate into your application. On the flipside it does not scale that well and as the amount of your async logic grows you might end up with rather complicated code.

Furthermore it might no longer be obvious, which action creators are pure and which have sideeffects attached to them, which further complicates the flow of your application logic (although naming conventions might help to lessen this issue). Also there is no built in way to handle advanced tasks like throttling, debouncing or cancellation.

Redux-Saga

According to its number of GitHub-Stars its fair to asume that Redux-Saga is probably the most popular solution when it comes to async middlewares. While its underlying concepts are far more complicated, than for example Redux-Thunk, it is way more powerful as well.

Redux-Saga makes heavy use of a rather new feature of the ECMAScript-standard called generator functions . In simple terms these are basically functions which can be paused, yielding a value to its caller. The caller in turn processes the given value and then resumes the generator function handing it back the processed value (generator functions basically expose an Iterator-like interface).

Within Redux-Saga generator functions are - surprise, surprise - by convention called sagas. The saga middleware exposes a set of helper functions to create declarative effects (plain javascript objects) that can be yielded by our sagas. The middleware will then handle the objects yielded behind the scenes.

The call()-helper for example lets us yield effects describing a function and its arguments to Redux-Saga. The described function will be called with the given arguments by the middleware and be processed further depending on its return value. If the result is a generator or iterator as well, it will be run like the parent generator (this basically enables saga-composition). If on the other hand the result is a promise, the middleware will first resolve the promise and then hand its result back to the saga that yielded the effect. Errors can simply be handled by surrounding your logic with try/catch-statements.

Example:

import { takeLatest, put, call } from 'redux-saga/effects'; // ACTION TYPES --------------------------------------------------------- const FETCH_USERS_START = "FETCH_USERS_START"; const FETCH_USERS_SUCCESS = "FETCH_USERS_SUCCESS"; const FETCH_USERS_FAILURE = "FETCH_USERS_FAILURE"; const actionTypes = { FETCH_USERS_START, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE, } // ACTIONS ------------------------------------------------------------- const actions = createActions({ FETCH_USERS_START : () => {}, FETCH_USERS_SUCCESS: users => ( { users } ), FETCH_USERS_FAILURE: errorMessage => ( { errorMessage } ) }); // REDUCERS ------------------------------------------------------------- const usersReducer = handleActions({ [FETCH_USERS_START]: (state, action) => Object.assign({}, state, { fetching: true }), [FETCH_USERS_SUCCESS]: (state, action) => Object.assign({}, state, { fetching: false, items: action.payload.users }), [FETCH_USERS_FAILURE]: (state, action) => Object.assign({}, state, { fetching: false, errorMessage: action.payload.errorMessage }) }, { items: [], fetching: false, errorMessage: '' }) // SAGAS ----------------------------------------------------------------- // The asterisk denotes that this is a generator function function * fetchUsersSaga() { // Spawns the specified generator whenever an action of the type FETCH_USERS_START // flows through our middleware. Running sagas from previous FETCH_USERS_START-actions // are cancelled automatically yield takeLatest(actionTypes.FETCH_USERS_START, fetchUsers) } function * fetchUsers() { try { // here we describe our api-request as effect const users = yield call(() => fetch("https://jsonplaceholder.typicode.com/users") .then(resp => resp.json())); // calling our action creator we create our fetchUsersSuccess-action object. // The 'put()' helper instructs Redux-Saga to dispatch the action on our redux store yield put(actions.fetchUsersSuccess(users)); } catch (e) { yield put(actions.fetchUsersFailure("damn :(")); } }

This time we have no additional logic attached to our action creators or reducers. Instead our async logic resides completely separate from our other redux logic. The takeLatest()-helper takes two arguments - pattern to match and a generator function. Now when an action of the type FETCH_USERS_START reaches our fetchUsersSaga() the fetchUsers()-generator will be spawned. If there is already an instance of fetchUsers() running it will be cancelled in favor of the new instance.

As described above we yield an effect object created with the call()-effect creator containing the invokation of the fetch()-API to the middleware. Redux-Saga in turn handles the promise returned by the API and either hands us the resolved response or throws an error we can handle inside our catch()-block. Note that the use of the fetch-API requires us to also chain an additional .then()-block to our fetch()-call where we convert the fetch result to our actual JSON-response.  Now we can use the put()-effect creator to instruct the middleware to dispatch a FETCH_USERS_SUCCESS-action which updates our store with the user data from the fetch-response.

Pros and Cons of Redux-Saga

Redux-Saga is an immensely powerful tool that comes packed with a lot of useful helper functions. E.g. it already has built in ways to handle advanced tasks like throttling, debouncing, race conditions and cancellation. It is also very well documented. The downsides are that you really have to understand generator functions first. While these are a powerful tool in you belt, they are currently rarely used outside of the Redux-Saga world, which means that this particular skill might not be as transferable to other fields as lets say Redux-Observable, which is built on reactive streams.

Redux-Saga code also tends to be rather imperative. Depending on your personal preferences this might be either a pro or a con. Another painpoint with sagas is testing. It is certainly doable, but can be quite confusing at times. Because sagas live outside of your other redux logic it also might not always be obvious, where sideeffects actually happen. 

Redux-Observable

Another popular solution for handling async operations is Redux-Observable. As the name already suggests Redux-Observable is built around the concept of observables and reactive streams. It is basically just a thin middleware wrapper around RxJS with just a few additional helper methods. Therefore you also need to add RxJS to your application if you want to use Redux-Observable.

Similar to how sagas are handled, sideeffects in Redux-Observable are separated from your other redux code and are handled in so called epics. An epic is basically just a function which takes a stream of actions and returns a new stream of actions. Note though, that an action already has flown through your reducers at the time it arrives in your epics.

Example:

import { of } from 'rxjs'; import { map, mergeMap, catchError } from 'rxjs/operators' import { ajax } from 'rxjs/ajax'; import { combineEpics, ofType } from 'redux-observable'; // ACTION TYPES ------------------------------------------------------------ const FETCH_USERS_START = "FETCH_USERS_START"; const FETCH_USERS_SUCCESS = "FETCH_USERS_SUCCESS"; const FETCH_USERS_FAILURE = "FETCH_USERS_FAILURE"; const actionTypes = { FETCH_USERS_START, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE, } // ACTIONS ------------------------------------------------------------- const actions = createActions({ FETCH_USERS_START : () => {}, FETCH_USERS_SUCCESS: users => ( { users } ), FETCH_USERS_FAILURE: errorMessage => ( { errorMessage } ) }); // REDUCERS ------------------------------------------------------------- const usersReducer = handleActions({ [FETCH_USERS_START]: (state, action) => Object.assign({}, state, { fetching: true }), [FETCH_USERS_SUCCESS]: (state, action) => Object.assign({}, state, { fetching: false, items: action.payload.users }), [FETCH_USERS_FAILURE]: (state, action) => Object.assign({}, state, { fetching: false, errorMessage: action.payload.errorMessage }) }, { items: [], fetching: false, errorMessage: '' }) // EPICS ------------------------------------------------------------- // Our action is not a regular redux action, but an observable stream of actions const fetchUserEpic = action => action.pipe( // Reacts to actions of type 'FETCH_USERS_START' ofType(actionTypes.FETCH_USERS_START), // Flattens created observable streams inside (is an alias for flatMap) mergeMap(() => // Makes an ajax request and creates a new observable stream from the response ajax.getJSON("https://jsonplaceholder.typicode.com/users").pipe( // Observable value that will be returned if no error was thrown map(response => actions.fetchUsersSuccess(response)), // Observable value that will be returned if an error was thrown // 'of' creates a new observable Stream catchError(() => of(actions.fetchUsersFailure('damn :('))) ) ), );

Whenever we dispatch an action, Redux-Observable takes the action after it has been dispatchted to the store, converts it into an observable stream and hands it to our epics. We can then use functional reactive programming magic to react to each action, e.g. filter by action type, add delays, or dispatch other actions in response.

In our example above our fetchUserEpic()-function filters the incoming stream of actions by our FETCH_USERS_START type. For each action of said type an AJAX-request is made and its result is mapped to another observable fetchUsersSuccess-action (or fetchUsersFailure on error) which will then be dispatched on our store. There are a few things to note here, though. For our AJAX-request we use the RxJS ajax-helper.

This helper makes the request and also turns its result into another observable stream. This resulting stream is then piped into the map() operator or on error the catchError()-operator. Because we now have an observable stream inside an observable stream - our stream of incoming actions basically contains the stream of resulting actions - we have to merge both streams into one stream. That's what the mergeMap()-operator does. It takes each incoming filtered action, maps it to the ajax-helper and flattens its result into another action that is part of the parent stream.

Pros and Cons of Redux-Observable

Redux-Observable basically shares most of the pros and cons of Redux-Saga. It ist very powerful, has cancellation, throttling etc. already baked in, but comes with a steep learning curve and somewhat esoteric testing via a testScheduler. Another downside to Redux-Observable is, that your async logic again is separated from your other redux related code.

Redux-Loop

Redux-Loop follows completely differenct concepts compared to the other libraries in this article. As some of you may know, Redux was heavily influenced by a small functional programming language called Elm. Redux-Loop is basically the missing link to make redux behave almost like Elm does. Instead of handling async operations separate from your other redux logic, they become part of your reducers. In practice your reducers are no longer able to just return a new state, but also to describe what will happen next by declaring so called commands.

Example:

import { createActions, handleActions } from 'redux-actions'; import { combineReducers, loop, Cmd } from 'redux-loop'; import { createSelector } from 'reselect'; // ACTION TYPES ------------------------------------------------------------- const FETCH_USERS_START = "FETCH_USERS_START"; const FETCH_USERS_SUCCESS = "FETCH_USERS_SUCCESS"; const FETCH_USERS_FAILURE = "FETCH_USERS_FAILURE"; export const actionTypes = { FETCH_USERS_START, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE, } // ACTIONS ------------------------------------------------------------- export const actions = createActions({ FETCH_USERS_START : () => {}, FETCH_USERS_SUCCESS: users => ( { users } ), FETCH_USERS_FAILURE: errorMessage => ( { errorMessage } ) }); // SIDEEFFECTS const fetchUsers = () => fetch("https://jsonplaceholder.typicode.com/users") .then(resp => resp.json()) // We might wrap our sideeffects inside an object to make their usage even // more explicit const sideEffects = { fetchUsers } // REDUCERS ------------------------------------------------------------- const usersReducer = handleActions({ // Note how we do not just return our new state, but make a call to the loop()-helper instead [FETCH_USERS_START]: (state, action) => loop( // State to return Object.assign({}, state, { fetching: true }), // Cmd that declaratively describes which function to run (our sideeffect) // and which respective actions to call on either success or failure Cmd.run(sideEffects.fetchUsers, { successActionCreator: actions.fetchUsersSuccess, failActionCreator: () => actions.fetchUsersFailure('damn :(') }) ), [FETCH_USERS_SUCCESS]: (state, action) => Object.assign({}, state, { fetching: false, items: action.payload.users }), [FETCH_USERS_FAILURE]: (state, action) => Object.assign({}, state, { fetching: false, errorMessage: action.payload.errorMessage }) }, { items: [], fetching: false, errorMessage: '' }) // IMPORTANT: We now use the redux-loop version of combineReducers instead of reduxs const dataReducer = combineReducers({ users: usersReducer, posts: (state = {}, action) => state });

In this example we declare a separate function to run our sideeffect, which should be invoked, whenever we dispatch a fetchUsersStart()-action. Inside our reducer we no longer just return our new state, but make a call to the loop()-helper function instead. This function takes two arguments: 1. the actual new state to return and 2. a command object describing effects to be run. In our case we describe a runnable command with Cmd.run() and specify our fetchUser()-function as effect to be executed. Furthermore we set the successActionCreator() and failActionCreator()-optionsThe former will be invoked if our fetch-promise resolves and the latter if it rejects.

Pros and Cons

Because our async handling resides directly next to our other redux logic it is really easy to follow the flow of actions.  Commands just declaratively describe effects and so our reducers as well as our action creators remain pure functions. This makes our code easily testable (Redux-Loop comes with a few helper functions for testing commands as well).

Another big pro is that there are just a few new concepts to learn. I assume that most developers will be able to pick up Redux-Loop almost as quickly as they would pick up Redux-Thunk. One major downside compared to Redux-Observable and Redux-Saga is, that Redux-Loop does not come with any additional helpers for cancellation, throttling or debouncing. So by default it is not quite as powerful as its major competitors.

Conclusion

If you need something powerful and battletested you can't really go wrong with either Redux-Saga or Redux-Observable. Both have a similar feature set and help you modelling complex async data flows. We at Sandstorm will probably continue using our approach of  sagas as  finite state-machines for most projects. For smaller personal projects I will probably use Redux-Loop, because I am a big fan of its mental model, which to me feels very natural to use.

The one library I really would not recommend to use is Redux-Thunk. Yes, it is easy to use, but in my experience it scales quite badly and often leads to brittle code. In my opinion action creators should remain pure and predictable and I do not want to guess if an action creator has any additional logic attached to it. If you just need something small, that is easy to integrate, I would recommend to use Redux-Loop instead. Of course as always your mileage may vary.