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 walkthrough 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:
Moving from Redux Thunk to Redux-Saga - Code Snippet - // 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 canceled 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 canceled. 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 function, 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.
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.
See FireHydrant in action
See how our end-to-end incident management platform can help your team respond to incidents faster and more effectively.
Get a demo