The redux-saga module is a plugin for redux that runs generator-based functions in response to redux actions. Redux-saga generator functions are nice because they behave like co: if you yield
a promise, redux-saga will unwrap the promise for you and throw a catchable error if the promise rejects. If you read The 80/20 Guide to ES2015 Generators, a simple saga should look familiar. However, redux-saga intends to keep using generators rather than async/await. In this article, I'll provide a basic example of using redux-saga, explain why redux-saga can't move to async/await, and consider whether you even need redux-saga in the first place.
Introducing Redux-Saga
Redux-saga's goal is to provide a mechanism for orchestrating complex
async operations with Redux. In many ways, redux-saga is an alternative
to redux-thunk, but redux-saga provides more functionality and a
different syntax. For example, suppose you wanted to load some data
from the GitHub API. Below is
a standalone Node.js example of using redux-saga to fetch()
data from
the GitHub API and put it in a redux store.
const fetch = require('node-fetch');
const { createStore, applyMiddleware } = require('redux');
const { call, put, takeLatest } = require('redux-saga/effects');
const util = require('util');
const sagaMiddleware = require('redux-saga').default();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(initSagas);
store.dispatch({ type: 'FETCH_USER', name: 'vkarpov15' });
// A _saga_ is a generator function that yields promises or redux-saga
// objects like the return value of `put()`
function* saga(action) {
try {
// If `fetch()` fails, redux-saga will throw a catchable error
let user = yield fetch(`https://api.github.com/users/${action.name}`);
user = yield user.json();
// `put()` is redux-saga's wrapper around `store.dispatch()`
yield put({ type: 'FETCH_USER_SUCCESS', user });
} catch (error) {
yield put({ type: 'FETCH_USER_ERROR', error });
}
}
// You need to map your sagas to action types. This function will
// ensure that `saga` runs every time a `FETCH_USER` action comes
// in, but only one `FETCH_USER` action can run at a time
function* initSagas() {
yield takeLatest('FETCH_USER', saga);
}
function reducer(state = {}, action) {
// Prints:
// "Action { type: '@@redux/INIT6.j.8.1.9' }"
// "Action { type: 'FETCH_USER', name: 'vkarpov15' }"
// "Action { type: 'FETCH_USER_SUCCESS', user: [Object] }"
console.log('Action', util.inspect(action, { colors: true, depth: 0 }));
switch (action.type) {
case 'FETCH_USER_SUCCESS': return Object.assign({}, state, action);
case 'FETCH_USER_ERROR': return Object.assign({}, state, action);
}
return state;
}
The saga()
generator function looks a lot like async/await, modulo some minor
differences like yield
on put()
. This syntax may seem strange,
but it has some powerful benefits. For example, takeLatest()
ensures that only the latest FETCH_USER
action runs through to
completion, even if you dispatch two nearly simultaneous FETCH_USER
actions.
store.dispatch({ type: 'FETCH_USER', name: 'vkarpov15', id: 1 });
// Redux-saga's `takeLatest` is smart enough to "cancel" the first FETCH_USER
// action and only execute this one. You'll only get one 'FETCH_USER_SUCCESS'
// action with `id = 2`
setImmediate(() => store.dispatch({ type: 'FETCH_USER', name: 'vkarpov15', id: 2 }));
function* saga(action) {
try {
let user = yield fetch(`https://api.github.com/users/${action.name}`);
user = yield user.json();
yield put({ type: 'FETCH_USER_SUCCESS', user, id: action.id });
} catch (error) {
yield put({ type: 'FETCH_USER_ERROR', error, id: action.id });
}
}
function* initSagas() {
yield takeLatest('FETCH_USER', saga);
}
function reducer(state = {}, action) {
// Prints:
// "Action { type: '@@redux/INIT6.j.8.1.9' }"
// "Action { type: 'FETCH_USER', name: 'vkarpov15', id: 1 }"
// "Action { type: 'FETCH_USER', name: 'vkarpov15', id: 2 }"
// "Action { type: 'FETCH_USER_SUCCESS', user: [Object], id: 2 }"
console.log('Action', util.inspect(action, { colors: true, depth: 0 }));
switch (action.type) {
case 'FETCH_USER_SUCCESS': return Object.assign({}, state, action);
case 'FETCH_USER_ERROR': return Object.assign({}, state, action);
}
return state;
}
No Async/Await?
While async/await and generators are similar, the fact remains that generators are considerably more powerful for advanced users. You can transpile async/await into generators, but you can't do the reverse. As a userland library, redux-saga can handle asynchronous behavior in ways that async/await doesn't.
The takeLatest()
behavior is an example of something that you
can't do with async/await: you can't abort an async function once
it has started unless the async function errors or returns.
Because redux-saga uses generators, it is responsible for calling
generator.next()
to continue the function after the function yields. So cancellation is easy:
just add an early return and don't call generator.next()
as
shown below.
const fetch = require('node-fetch');
const util = require('util');
// Prints:
// "{ type: 'FETCH_USER_SUCCESS', user: [Object], id: 2 }"
const put = action => console.log(util.inspect(action, { colors: true, depth: 0 }));
function* saga(action) {
try {
let user = yield fetch(`https://api.github.com/users/${action.name}`);
user = yield user.json();
yield put({ type: 'FETCH_USER_SUCCESS', user, id: action.id });
} catch (error) {
yield put({ type: 'FETCH_USER_ERROR', error, id: action.id });
}
}
function* saga(action) {
try {
let user = yield fetch(`https://api.github.com/users/${action.name}`);
user = yield user.json();
yield put({ type: 'FETCH_USER_SUCCESS', user, id: action.id });
} catch (error) {
yield put({ type: 'FETCH_USER_ERROR', error, id: action.id });
}
}
// Toy example of how you can make a generator cancellable
const cancellable = function(generator) {
let cancelled = false;
next();
function next(v) {
// Was `cancel()` called? If so, don't go on to the next step
if (cancelled) {
return;
}
// Otherwise, go through to the next step and check for promises
const { value, done } = generator.next(v);
if (done) {
return;
}
if (value != null && typeof value.then === 'function') {
return value.then(
res => next(res),
err => generator.throw(err)
);
}
next(value);
}
return { cancel: () => cancelled = true };
};
const call1 = cancellable(saga({ name: 'vkarpov15', id: 1 }));
setImmediate(() => {
cancellable(saga({ name: 'vkarpov15', id: 2 }));
// This will cancel the first action while the `fetch()` is in flight,
// so it will never dispatch a 'FETCH_USER_SUCCESS' action
call1.cancel();
});
Do You Need Redux-Saga?
There's certainly advantages to takeLatest()
, particularly if you
have a good reason to want to avoid more than one action of a given
type from taking place at the same time. However, is there a practical
advantage to taking the latest instance of an action and cancelling
previous ones rather than just taking the first one? I can't think
of any use cases other than autocompletes.
If you just want to ensure only one instance of a given action runs at any one time, you can write your own redux middleware to handle it.
const fetch = require('node-fetch');
const { createStore, applyMiddleware } = require('redux');
const util = require('util');
const inflight = {};
const dedupeMiddleware = store => next => action => {
if (action.payload == null || action.payload.constructor.name !== 'AsyncFunction') {
// If `action.payload` isn't a function, we can't really cancel this action, and
// if this function isn't async then assume it is sync
return next(action);
}
if (inflight[action.type]) {
// Ignore if there's an action with this type already in progress
return;
}
inflight[action.type] = true;
action.payload(action).then(
() => { inflight[action.type] = false; },
() => { inflight[action.type] = false; }
);
next(action);
};
const store = createStore(reducer, applyMiddleware(dedupeMiddleware));
store.dispatch({ type: 'FETCH_USER', name: 'vkarpov15', id: 1, payload: fetchUser });
setImmediate(() => store.dispatch({ type: 'FETCH_USER', name: 'vkarpov15', id: 2, payload: fetchUser }));
async function fetchUser({ name, id }) {
try {
let user = await fetch(`https://api.github.com/users/${name}`);
user = await user.json();
store.dispatch({ type: 'FETCH_USER_SUCCESS', user, id });
} catch (error) {
store.dispatch({ type: 'FETCH_USER_ERROR', error, id });
}
}
function reducer(state = {}, action) {
// Prints:
// "Action { type: '@@redux/INITy.d.z.l.g' }"
// "Action { type: 'FETCH_USER', name: 'vkarpov15', id: 1, payload: [AsyncFunction: fetchUser] }"
// "Action { type: 'FETCH_USER_SUCCESS', user: [Object], id: 1 }"
// 2nd action is ignored!
console.log('Action', util.inspect(action, { colors: true, depth: 0 }));
switch (action.type) {
case 'FETCH_USER_SUCCESS': return Object.assign({}, state, action);
case 'FETCH_USER_ERROR': return Object.assign({}, state, action);
}
return state;
}
Moving On
Redux-saga is a very interesting library and it does a good job of highlighting where you might want to use generators instead of async/await. In general, I don't see much benefit to using redux-saga over plain old async/await, but the ability to cancel in-flight sagas automatically is pretty cool.
Want to learn how to identify whether your favorite npm modules work with async/await without cobbling together contradictory answers from Google and Stack Overflow? Chapter 4 of my new ebook, Mastering Async/Await, explains the basic principles for determining whether frameworks like React and Socket.IO support async/await. Get your copy!