Moving from Redux Thunk to Redux-Saga: A walk-through

Mandy Mak October 21, 2020

TechTutorial

At FireHydrant, we recently began to replace our usage of thunks with Sagas to handle our data fetching.

Why it’s a good idea, directly from the Redux Saga docs: “Contrary to redux thunk, you don’t end up in callback hell, you can test your asynchronous flows easily and your actions stay pure.”

While we are already reaping each of these benefits, the one that stood out to us is the ability to test asynchronous flows easily. While it is not impossible to test thunks, it does require mocking API calls (and any other functions called within them), resulting in clunkier, less readable tests. As the FireHydrant system continues to grow, so does our test coverage. The decision to transition to Redux-Saga prepares us to write clean and readable tests easily, reinforcing one of our company themes: developer happiness.

In the sections that follow, I will walk you through how to replace thunks in your application with Sagas, from refactoring the actions and reducer to writing Sagas and applying Saga middleware to the Redux store.

Note: This walk-through assumes that you are using Redux for state management, with Redux Thunk as the middleware.

Refactor the actions

Let’s start with the action creators, containing three simple actions.

A quick distinction between action creators, actions, and action types: Action creators are functions. All they do is return an action. Actions are what’s returned from action creators. They are typically plain old JavaScript objects (POJOs), but sometimes are functions (like in the case of using Redux Thunk). Finally, action types are strings that describe an action. They are usually stored as constants. (Despite these technical distinctions, action creators are often referred to simply as actions, which is a-okay, as long as there’s a mutual understanding.)

Using Redux Thunk, our actions file currently looks like this:

// actions/takoyaki.js (redux-thunk)
import firehydrantAPI from 'helpers/firehydrantAPI';
export const loadTakoyaki = data => dispatch => {
dispatch(loadTakoyakiRequest());
const path = 'takoyaki';
firehydrantAPI.get(path)
.then(response => dispatch(loadTakoyakiSuccess(response.data)))
.catch(dispatch(loadTakoyakiError));
};
export const LOAD_TAKOYAKI_REQUEST = 'LOAD_TAKOYAKI_REQUEST';
export const loadTakoyakiRequest = () => ({
type: LOAD_TAKOYAKI_REQUEST,
});
export const LOAD_TAKOYAKI_SUCCESS = 'LOAD_TAKOYAKI_SUCCESS';
export const loadTakoyakiSuccess = data => ({
type: LOAD_TAKOYAKI_SUCCESS,
data,
});
export const LOAD_TAKOYAKI_ERROR = 'LOAD_TAKOYAKI_ERROR';
export const loadTakoyakiError = error => ({
type: LOAD_TAKOYAKI_ERROR,
error,
});

You’ll see that whereas most of the above action creators each return a POJO, loadTakoyaki returns a function. This inner function, or thunk, which receives the Redux store’s method dispatch, is Redux Thunk’s “cue” to invoke the thunk. Otherwise, if the action creator returns a POJO, Redux Thunk simply ignores it, and the action object is processed by the reducer.

With the use of Sagas, one approach is to refactor the loadTakoyaki action creator to the following:

export const loadTakoyaki = data => ({
type: LOAD_TAKOYAKI,
data,
});

But our actions file can afford to be even cleaner, shown below:

// actions/takoyaki.js (redux-saga)
export const LOAD_TAKOYAKI = 'LOAD_TAKOYAKI';
export const LOAD_TAKOYAKI_SUCCESS = 'LOAD_TAKOYAKI_SUCCESS';
export const LOAD_TAKOYAKI_ERROR = 'LOAD_TAKOYAKI_ERROR';
export const loadTakoyaki = data => ({
type: LOAD_TAKOYAKI,
data,
});

You can see here that our new loadTakoyaki action creator no longer uses loadTakoyakiSuccess nor loadTakoyakiError in its callback — it simply returns a plain object. (Hooray, pure actions!)

Next, we will write our Sagas and see how the LOAD_TAKOYAKI_SUCCESS and LOAD_TAKOYAKI_ERROR are processed.

Write some Sagas

This is where the Saga fun happens!

A Saga is a thread in the application that is responsible only for side effects, e.g., asynchronous data fetching. Redux-Saga uses an ES6 feature called generators to create the asynchronous layer of code. Whereas we typically expect “normal” functions to run to completion once they start running, generator functions differ in that they can pause and resume, as well as be cancelled and restarted. I recommend checking out Kyle Simpson’s article on ES6 generators, if you would like to learn more.

Let’s create a new file to store our Sagas.

// sagas/takoyaki.js (redux-saga)
import { call, put, takeLatest } from 'redux-saga/effects';
import firehydrantAPI from 'helpers/firehydrantAPI';
import * as actions from '../actions/takoyaki';
// worker Saga
function* loadTakoyaki(action) {
try {
const response = yield call(firehydrantAPI.get, 'takoyaki');
yield put({ type: actions.LOAD_TAKOYAKI_SUCCESS, data: response.data });
} catch (e) {
console.error(e);
yield put({ type: actions.LOAD_TAKOYAKI_ERROR, error: e.message });
}
}
// watcher Saga
function* takoyakiSaga() {
yield takeLatest(actions.LOAD_TAKOYAKI, loadTakoyaki);
}
export default takoyakiSaga;

Both the watcher and worker Sagas are generator functions, which are denoted with an asterisk (*) beside the function keyword.

A watcher Saga watches for when actions are dispatched to the Redux store. In the above snippet, takoyakiSaga watches for actions that match the pattern LOAD_TAKOYAKI, then triggers the saga task loadTakoyaki. Since we are using the takeLatest helper function here, when LOAD_TAKOYAKI is dispatched, loadTakoyaki will start in the background, and any pending loadTakoyaki tasks that started prior will be cancelled. Therefore, takeLatest ensures that when LOAD_TAKOYAKI is dispatched rapidly multiple times in succession, only the final invocation of loadTakoyaki will complete, either by dispatching LOAD_TAKOYAKI_SUCCESS or LOAD_TAKOYAKI_ERROR, depending on the success of the API call.

A worker Saga is the one that handles the data fetching. When triggered, the call effect is used to run an async function (e.g., an API call), and the result (a Promise) is stored in the response variable. If the API call is successful (i.e., the Promise stored in response is resolved), then the action LOAD_TAKOYAKI_SUCCESS is dispatched to the store, passing along the response data. If the API call fails, then we dispatch LOAD_TAKOYAKI_ERROR and include the error.

Some additional items worth noting:

A yield expression takes the form of yield _______, and it is the keyword yield that allows pausing/resuming a generator function. The yield call(firehydrant.get, 'takoyaki') expression sends the call(firehydrant.get, 'takoyaki') value out when pausing the loadTakoyaki generator function at that point. When the generator resumes, whatever value that is sent in will be assigned to the variable response. There is yet another yield expression on the following line that puts the Redux action LOAD_TAKOYAKI_SUCCESS, along with response as the payload.

Redux-Saga fulfills each yielded Effect differently, based on its type. For example, if it is a call, then the middleware will call the provided funcion, e.g., firehydrant.get with 'takoyaki' as the argument. If it is a put, it will dispatch an action to the Redux store, such as LOAD_TAKOYAKI_SUCCESS, in the case of a successful API call. Effect creators like call and put do not perform any executions – they are instead handled by the middleware. And since they return POJOs, this makes them easy to test.

You will also notice that the loadTakoyaki worker Saga is written using try/catch syntax. This is a way of catching errors inside the worker Saga. What’s placed in the try block is the code that will be tested for errors while it is executed. If an error occurs, then the try block will be exited, and the catch block is then executed. In the event that there are no errors encountered in the try block, then the catch block is ignored.

If you happen to be a visual learner, I’ve included a try/catch flowchart below.

try-catch-flowchart

Update the reducer

We won’t need to make any changes to the reducer file, except changing LOAD_TAKOYAKI_REQUEST to LOAD_TAKOYAKI.

// reducers/takoyaki.js (redux-saga)
/* eslint-disable no-param-reassign */
import produce from 'immer';
import * as actions from '../actions/takoyaki';
const initialState = {
loading: false,
error: null,
data: {},
};
const takoyaki = (state = initialState, action) => produce(state, draftState => {
switch (action.type) {
case actions.LOAD_TAKOYAKI:
draftState.loading = true;
break;
case actions.LOAD_TAKOYAKI_SUCCESS:
draftState.data = action.data;
draftState.loading = false;
draftState.error = null;
break;
case actions.LOAD_TAKOYAKI_ERROR:
draftState.loading = false;
draftState.error = action.error;
break;
default:
break;
}
});
export default takoyaki;

Note: Use immer! It’ll make your code DRY-er.

Apply Saga middleware to the store

Finally, we’ll make a few changes to the store.

Using thunk middleware, our Redux store currently looks like this:

// store.js (redux-thunk)
import {
createStore, applyMiddleware, compose, combineReducers,
} from 'redux';
import thunk from 'redux-thunk';
import takoyaki from './reducers/takoyaki';
const middlewares = [thunk];
let enhancers;
if (process.env.NODE_ENV === 'development') {
enhancers = compose(
applyMiddleware(...middlewares),
window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f
);
} else {
enhancers = compose(
applyMiddleware(...middlewares),
);
}
const rootReducer = combineReducers({
takoyaki,
});
const store = createStore(rootReducer, {}, enhancers);
export default store;

In order for Redux-Saga to work with Redux, we will need to first create the Saga middleware.

const sagaMiddleware = createSagaMiddleware();

We will then apply it to the Redux store by including it in the middlewares array.

const middlewares = [sagaMiddleware];

Finally, we will import and run the watcher Saga, so that it will trigger the worker Saga when LOAD_TAKOYAKI is dispatched.

// Import the watcher saga that we created earlier
import takoyakiSaga from './sagas/takoyaki';
// Run the middleware by placing this line after you've created the store
sagaMiddleware.run(takoyakiSaga);

And if you need to run multiple Sagas, you absolutely can!

sagaMiddleware.run(takoyakiSaga);
sagaMiddleware.run(karaageSaga);
sagaMiddleware.run(yakitoriSaga);

Note: Your choice of Redux middleware doesn’t need to be an either/or decision. It is absolutely possible to use both Redux Thunk and Redux-Saga. All you’d need to do is restore the redux-thunk import and include thunk in the middlewares array.

const middlewares = [thunk, sagaMiddleware];

Closing

Redux-Saga is a middleware library that makes handling application side effects simple and testable. The transition from Redux Thunk to Redux-Saga allows the asynchronous layer of code to be more easily tested and free of callback hell and impure actions.

That’s all from me, folks! I hope this post has been helpful. If you have any questions, you can find me on Twitter @_mandymak.

You just got paged. Now what?

FireHydrant helps every team master incident response with straightforward processes that build trust and make communication easy. Learn how.

Learn more about FireHydrant

We’re working on a suite of tools to make managing complex systems easier, for everyone. For free.

Try It For Free