Redux-Saga – handling asynchronous actions in Redux

Adrian Gut

Redux-Saga is a popular library that aims to make it easier to manage side effects in a Redux application. Side effects refer to code that performs operations that can potentially modify the state of an application, such as making an API call or interacting with a database. In a Redux application, it is important to keep the management of side effects separate from the management of the application’s state, as this can make it easier to write, test, and maintain the code.

One way to manage side effects in a Redux application is to use redux-saga. This library uses a concept called “sagas” to handle side effects. A saga is a generator function that can be used to perform asynchronous tasks, such as making an API call, in a declarative way.

Installation

Redux-Saga is available via NPM. You can install it with the following command:

npm i redux-saga

Usage

Next, you need to create a saga that will handle the side effect. A simple example of a saga might look like this:

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

export function* fetchData(action) {
    try {
        const data = yield call(fetch, action.payload.url);
        yield put({ type: 'FETCH_SUCCEEDED', data });
    } catch (e) {
        yield put({ type: 'FETCH_FAILED', message: e.message });
    }
}

function* watchFetchData() {
    yield takeEvery('FETCH_REQUESTED', fetchData);
}

export default function* rootSaga() {
    yield all([
        watchFetchData()
    ]);
}

In this example, the fetchData saga is responsible for making an API call when it receives a FETCH_REQUESTED action. If the API call is successful, it will dispatch a FETCH_SUCCEEDED action with the data returned from the API. If the API call fails, it will dispatch a FETCH_FAILED action with an error message.

The watchFetchData saga is responsible for watching for FETCH_REQUESTED actions and triggering the fetchData saga when one is received. Finally, the rootSaga function combines all of the sagas in the application into a single generator that can be run by the Redux-Saga middleware.

To use the sagas in a Redux application, you need to apply the Redux-Saga middleware to the Redux store:

import createSagaMiddleware from 'redux-saga';
import rootSaga from './sagas';

const sagaMiddleware = createSagaMiddleware();

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

sagaMiddleware.run(rootSaga);

With the middleware in place, the sagas will be able to listen for actions dispatched to the store and perform the necessary side effects.

Testing

Core

Asynchronous effects are usually difficult to model and test properly due to having external dependencies that might cause test failure even if the process was done correctly. Fortunately, Redux-Saga allows us to benefit from the features of generator functions and the yield keyword. Additionally, call and put create plain objects describing the action, so checking whether the expected and received values are equal is enough.

To further explain the process, let’s create a simple saga that increments or decrements a value based on user input, and after a delay:

const requestValue = (value) => ({
    type: 'REQUEST_VALUE',
    payload: { value },
});

const delay = (ms) => new Promise(res => setTimeout(res, ms));

function* incrementOrDecrementAfterDelay() {
    const action = yield take('REQUEST_VALUE');
    yield delay(1000);
    if (action.payload.value > 0) {
        yield put({ type: 'INCREMENT' });
    } else {
        yield put({ type: 'DECREMENT' });
    }
}

Testing the generator function is a straightforward process, like the one below:

import test from 'tape';
import { call, put, take } from 'redux-saga/effects';
import { delay, incrementOrDecrementAfterDelay } from './sagas';

test('incrementOrDecrementAfterDelay test', (assert) => {
    const gen = incrementOrDecrementAfterDelay();

    assert.deepEqual(
        gen.next().value,
        take('REQUEST_VALUE'),
        'it should take user input',
    );

    assert.deepEqual(
        gen.next().value,
        call(delay, 1000),
        'it should call delay(1000)',
    );

    const positiveUserInput = 2;

    assert.deepEqual(
        gen.next(requestValue(positiveUserInput)).value,
        put({ type: 'INCREMENT' }),
        'it should increment after a positive value',
    );

    assert.deepEqual(
        gen.next().done,
        true,
        'it should be done',
    );
});

But the code is branching, and we have only tested a single branch. Redux-Saga provides a utility function cloneableGenerator that helps test code without needless repetition. The updated code looks as follows:

import test from 'tape';
import { call, put, take } from 'redux-saga/effects';
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { delay, incrementOrDecrementAfterDelay } from './sagas';

test('incrementOrDecrementAfterDelay test', (assert) => {
    const gen = cloneableGenerator(incrementOrDecrementAfterDelay)();

    assert.deepEqual(
        gen.next().value,
        take('REQUEST_VALUE'),
        'it should take user input',
    );

    assert.deepEqual(
        gen.next().value,
        call(delay, 1000),
        'it should call delay(1000)',
    );

    assert.test('user input a positive number', (a) => {
        const cloned = gen.clone();
        const positiveUserInput = 2;

        a.deepEqual(
            cloned.next(requestValue(positiveUserInput)).value,
            put({ type: 'INCREMENT' }),
            'it should increment after a positive value',
        );

        a.deepEqual(
            cloned.next().done,
            true,
            'it should be done',
        );

        a.end();
    });

    assert.test('user input a nonpositive number', (a) => {
        const cloned = gen.clone();
        const nonpositiveUserInput = -2;

        a.deepEqual(
            cloned.next(requestValue(nonpositiveUserInput)).value,
            put({ type: 'DECREMENT' }),
            'it should decrement after a nonpositive value',
        );

        a.deepEqual(
            cloned.next().done,
            true,
            'it should be done',
        );

        a.end();
    });
});

Both branches of incrementOrDecrementAfterDelay have been successfully tested.

Error Handling

Functions sometimes involve error handling, whether due to incorrect values, failed requests, undesired states or inputs, etc. For the record, let’s assume 0 is an undesired state and should throw:

function* incrementOrDecrementAfterDelay() {
    try {
        const action = yield take('REQUEST_VALUE');
        yield delay(1000);
        if (action.payload.value > 0) {
            yield put({ type: 'INCREMENT' });
        } else if (action.payload.value < 0) {
            yield put({ type: 'DECREMENT' });
        } else {
            throw new Error('0 cannot cause incrementation or decrementation!');
        }
    } catch (e) {
        yield put({ type: 'INCREMENT_OR_DECREMENT_AFTER_DELAY_FAILED', error });
    }
}

Handling this case within our test is a matter of using the generator’s .throw() method. Updating the test with a new case looks as follows:

// imports
// ...

test('incrementOrDecrementAfterDelay test', (assert) => {
    const gen = cloneableGenerator(incrementOrDecrementAfterDelay)();

    // rest of the function body
    // ...

    assert.test('user input an incorrect value', (a) => {
        const cloned = gen.clone();
        const fakeError = {};

        a.deepEqual(
            cloned.throw(fakeError).value,
            put({ type: 'INCREMENT_OR_DECREMENT_AFTER_DELAY_FAILED', error }),
            'it should catch a thrown error after receiving an incorrect value',
        );

        a.end();
    });
});

Utilities

Fork

fork creates an attached fork in the background. This allows the code following the fork in a saga to resume execution. The saga will not terminate until all attached forks terminate, even if the saga body has already terminated.

In the case of an error, Sagas abort if the main body throws an error or any attached fork raises an uncaught error. Since forks are executed in the background, the main body might already have finished execution of its own code and as such, Sagas generally cannot catch errors from forked tasks.

An aborting Saga cancels itself and all of its forks, and then raises an error.

Spawn

spawn created a detached fork. Detached forks do not share the execution context with the parent Saga – that means a parent does not wait for their termination, and any uncaught errors are not bubbled up to the parent.

Since the execution context is different for each detached fork, that means:

  • the parent Saga will not cancel spawned forks, even if it aborts
  • one detached fork can abort without affecting other forks

This is useful in case of tasks where a failure of one does not impact other tasks. It also means you have to manually cancel spawned forks if you wish to do so.

Promise-like features

Redux-Saga includes some helpful functions that are similar to Promise methods, such as:

  • all resembles Promise.all() – the effects passed to the function will be executed in parallel, but the main function will only resume once everything is finished
  • race resembles Promise.race() – the first task to resolve or reject is returned, the others are cancelled

Conclusions

Redux-saga is a powerful tool that can make it easier to manage side effects in a Redux application. It allows you to declaratively specify the asynchronous tasks that need to be performed in response to actions, making it easier to write, test, and maintain your code.

Meet the geek-tastic people, and allow us to amaze you with what it's like to work with j‑labs!

Contact us