Redux: Thunk vs. Saga

Two of the most common ways of dealing with side effects in Redux applications today are thunks and sagas.

Thunk: You Got Function All In My Actions

Most people will ask, what's a thunk? And why do I need it?

The most straightforward explanation of a thunk I've seen is by Kyle Simpson (of You Don't Know JS fame) in his Rethinking Asynchronous Javascript course for Front End Masters (highly recommended!).

A thunk is a function that already has everything it needs to execute.

What does that have to do with redux thunk? I'm not sure either.

As you know, in Redux, actions are defined by JSON. Redux-thunk allows you to send a function instead. Rather than [only] prescribe a state change for the reducer to carry out, you can write some logic to execute immediately and dispatch other actions.

This is great for small use cases, however for more than a few functions, a better approach is middleware ... or sagas.

How do I use this thing?

Add the redux-thunk middleware to the store.

import { createStore, applyMiddleware, compose } from "redux";
import thunkMiddleware from "redux-thunk";
import rootReducer from "../reducers";

const createStoreWithMiddleware = compose(
  applyMiddleware(thunkMiddleware)
)(createStore);

export default function configureStore(initialState) {
  const store = createStoreWithMiddleware(rootReducer);
  return store;
}

configureStore.js

This file just exports a function that returns the store to be added to the Provider element.

Using compose to create the createStoreWithMiddleware function is just a fancier way of writing

const store = createStore(
	rootReducer,
    applyMiddleware(thunkMiddleware)
);

Write an action that returns a function. This function will take the dispatch method as a parameter. You can use this to call subsequent actions.

import * as types from "../constants/ActionTypes";

export function receiveBooks(data) {
  return {
    type: types.RECEIVE_BOOKS,
    books: data.books,
    categories: data.categories,
    genres: data.genres
  };
}

export function fetchBooks() {
  return dispatch => {
    fetch("/books.json").then(response => {
    	const data = response.json();
    	dispatch(receiveBooks(data));
    })
    .catch(error => 
		dispatch({ type: types.FETCH_FAILED, error })
    );
  };
}

book-actions.js

In this action creator file, we have one standard action, receiveBooks() and one thunk(ed) action, fetchBooks(). fetchBooks() just defines an arrow function taking dispatch as a parameter. The receiveBooks action is dispatched on success.

Saga: A Heroic Tale Of Dispatching

"Again with the names, what's a saga?!" If you are or were a fan of fantasy or science fiction, you already know. For the others, a saga is just a series of connected stories.

For the case of redux-saga, I'll let the README do the honor:

Sagas are responsible for orchestrating complex/asynchronous operations.

Sagas are created using Generator functions.

Since sagas take advantage of the yield keyword to halt execution within a function, they empower you to write the steps necessary to complete your action and let the Javascript engine manage the execution.

Executing functions in series can otherwise be tricky, your other option being a promise chain.

How do I use this thing?

Add redux-saga middleware to the store.

import { createStore, applyMiddleware, compose } from "redux";
import createSagaMiddleware from "redux-saga";
import rootReducer from "../reducers";

const sagaMiddleware = createSagaMiddleware();

const createStoreWithMiddleware = compose(
  applyMiddleware(sagaMiddleware)
)(createStore);

export default function configureStore(initialState) {
  const store = createStoreWithMiddleware(rootReducer);
  return store;
}

configureStore.js

Define your saga.

import { takeLatest } from "redux-saga"
import { call, put } from "redux-saga/effects"

function* fetchBooks(path) {
   try {
      const data = yield call(fetch, path);
      yield put({type: "RECEIVE_BOOKS", data });
   } catch (e) {
      yield put({type: "FETCH_FAILED", message: e.message});
   }
}

function* fetchSaga() {
  yield* takeLatest("FETCH_BOOKS", fetchBooks);
}

export default fetchSaga;

sagas/index.js

fetchSaga() listens for all FETCH_BOOKS actions and calls fetchBooks() once received. takeLatest() just means that if there are multiple actions fired, the most recent will be run and previous ones will be cancelled.

What's the benefit of using a saga here?

Since fetchBooks() as a saga is a Generator function, the call to the fetch api with the yield keyword will block until the promise is resolved. Also, since it is blocking, we can make use of try/catch for error handling.

Another benefit is testing. The call and put methods return javascript objects, so in unit tests you can simply test each value yielded by your saga function with equality comparison. Testing thunks often requires complex mocking of the fetch api or other functions.

Import and run saga.

import { createStore, applyMiddleware, compose } from "redux";
import createSagaMiddleware from "redux-saga";
import rootReducer from "../reducers";
import fetchSaga from "../sagas";

const sagaMiddleware = createSagaMiddleware();

const createStoreWithMiddleware = compose(
  applyMiddleware(sagaMiddleware)
)(createStore);

export default function configureStore(initialState) {
  const store = createStoreWithMiddleware(rootReducer);
  sagaMiddleware.run(fetchSaga);
  return store;
}

configureStore.js

Since we already added the saga middleware to the store, the only thing that remains is to import and run the saga we defined.