Redux Sagas

Flow digram in a Redux application

In this article, I will walk you through the basics of Redux Saga and how to use it. But before jumping into it, let’s brush up our knowledge of Redux a little bit:

A frontend application architecture using Redux consists of

  1. A store holding an immutable application’s state.
  2. The UI components that use the application state to render itself. These components can be a pure functions, only caring about producing the right presentational content based on the state.
render = (state) => view

3. Components that can dispatch actions to the store.

dispatch(loadTodo(text))

4. The state is mutated using reducers which are simple pure functions to produce new state.


reducer = (action) => newState

While this looks pretty straightforward, things get more complicated when we need to handle async actions (often called side effects in functional programming). And that’s where redux-saga comes into rescue.

Why Sagas?

In a large application you tend to have loads of async actions, all handling their own side effects. Instead of scattering them, you group logically related pieces of behavior in a program called saga.

What Are Sagas?

Sagas, are declarative and well organized way to express the side-effects. They are useful when you need a process to coordinate with multiple action creators and side-effects.

Imagine saga like a separate thread in your application that’s solely responsible for side effects. This thread can be started, paused and cancelled from the main application with normal redux actions, it has access to the full redux application state and it can dispatch redux actions as well.

Whether you use promises, callbacks, try/catch blocks, sagas help you turn these long functions into simple, readable instructions, allowing you to organize and remove much clutter and confusion from that actions and reactions that litter your codebase.

Sagas are implemented as ES6 Generator functions that yield objects to the redux-saga middleware. The yielded objects are a kind of instruction to be interpreted by the middleware. When a Promise is yielded to the middleware, the middleware will suspend the Saga until the Promise completes. These generators make the asynchronous flows easy to read, write and test.

Among the most popular are:

  • takeLatest(action, saga, ...args) — starts a new saga (generator function) task in the background when the specified action is dispatched. If a saga task was started previously (on the last action dispatched before the actual action), and if this task is still running, the task will be cancelled. Also you can pass arguments to the saga task.
  • put(action) — dispatch an action to the redux store.
  • call(fn, ...args) — calls the function fn with the specified args. The function can be either normal or a generator function.if the result is a promise object it waits for it to resolve and returns what the promise resolved to.

For example, let’s see how to fetch todos using redux saga effects where we would like to trigger the saga each time a special action is dispatched to the store. (LOAD_TODOS in this case)

import {takeLatest} from "redux-saga";
import {call, put} from "redux-saga/effects";
function* watchTodos() {
yield takeLatest('LOAD_TODOS', loadTodos);
}
function* loadTodos() {
yield put({ type: 'FETCHING_TODOS' });
const {error, todos} = yield call(fetch, '/todos');
if(error) {
yield put({ type: 'ERROR_FETCHING_TODOS', error });
}
yield put({ type: 'FETCHED_TODOS', payload: todos });
}

Trigger and combining multiple sagas

Sagas are “they are like daemon tasks that run in the background and choose their own logic of progression” — Yassine Elouafi creator of redux-saga.

So let’s see now how we can bind multiple sagas to the Redux application workflow.

./saga.js

import { takeLatest, all } from 'redux-saga/effects';

...

export default function *watchAll() {
yield all([
yield takeLatest(LOAD_TODOS, loadTodos);
yield takeLatest(CREATE_TODOS, createTodos);
]);
}

./main.js

import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'

import rootReducer from './reducers'
import rootSaga from './sagas'


const sagaMiddleware = createSagaMiddleware()
const store = createStore(
rootReducer,
applyMiddleware(sagaMiddleware)
)
sagaMiddleware.run(rootSaga)

In this example, Redux Saga uses the all function to combine multiple sagas to one root saga for the Redux store. And sagaMiddleWare.run(rootSaga) is the glue between Redux store and the generator functions.

Saga testing:

Sagas are very easy to test as they pure and there is a separation of concerns involving side effects. Testing async functions can be painful sometimes. Sagas take the pain out of async tests and making it easier to test our code.

Let’s us write the test for our above todos example.

import { call, put, takeLatest } from 'redux-saga';
const myTodos = [{ message: 'text', done: false }];
const error = "loading todos failed";
it("test watchTodos for takeLatest", () => {
const gen = watchTodos();
expect(gen.next().value.name).toEqual(takeLatest(LOAD_TODOS, loadTodos).name);
});
it("test loadTodos for success", () => {
const gen = loadTodos();
expect(gen.next().value).toEqual(call(fetch, '/todos'));
expect(gen.next(myTodos).value).toEqual(put({ type: 'FETCHED_TODOS', payload: myTodos }));
});
it("test loadTodos for failure", () => {
const gen = loadTodos();
expect(gen.next().value).toEqual(call(fetch, '/todos'));
expect(gen.next({error}).value).toEqual(put({type: ERROR_FETCHING_TODOS, error}));
});

Saga error handling

We can catch errors inside the Saga using the familiar try/catch syntax. When an error is thrown inside the saga, if it has a try/catch block surrounding the current yield instruction, the control will be passed to the catch block. Otherwise, the saga aborts with the raised error, and if this saga was called by another saga, the error will propagate to the calling saga.

If you want to avoid try/catch block, you can also make your API service return a normal value with some error flag on it. For example, you can catch Promise rejections and map them to an object with an error field.

An example of that would be:

import Api from './path/to/api'
import { call, put } from 'redux-saga/effects'

function fetchTodosApi() {
return Api.fetch('/products')
.then(response => ({ response }))
.catch(error => ({ error }))
}

function* fetchProducts() {
const { response, error } = yield call(fetchTodosApi)
if (response)
yield put({ type: 'TODOS_REQUEST_SUCCESS', todos: response })
else
yield put({ type: 'TODOS_REQUEST_FAILED', error })
}

Hope you find this article useful!