JavaScript Power Tools: redux-saga
At Formidable, we're always refining our knowledge of modern JavaScript tech. This blog series, entitled "JavaScript Power Tools", will take a deeper look at some of the tools, frameworks, and libraries we use to deliver reliable and maintainable code.
Today, I want to talk about redux-saga
. You've probably heard about this
library while researching patterns for managing asynchronous behavior in Redux
applications. It's a powerful concurrency tool with all sorts of use cases
beyond just making API calls.
In this article, we'll take a look at redux-saga
's core mechanism (the "saga")
and how it's implemented. The next article will be a 'field guide' to the
concurrency patterns it provides, and the final article will demonstrate a
few real-world use cases that benefit from this approach.
What is a Saga?
redux-saga
is named after the 'Saga pattern', a server-side architectural
pattern introduced nearly 30 years ago, in which individual business-logic
processes within a large distributed system avoid making multiple simultaneous
connections to databases or services. Instead, they send messages to a central
'execution coordinator' which is responsible for dispatching requests on their
behalf. When designed in this way, such processes are termed 'sagas'. We could
spend a lot of time talking about the justification for this approach, but for
now let's simply observe that they were originally designed as an approach for
writing code in highly asynchronous, performance-sensitive environments, and
that in this regard, the browser has a lot in common with a distributed system.
So, with this in mind, we think of redux-saga
as the piece of our system which
coordinates the operation of a bunch of different interleaved "sagas". These
sagas take the form of JavaScript generator functions used in a slightly
unusual fashion. To understand this, let's take a look at the typical usage of
generators, and work our way back from there.
From Generators to Sagas
Here's a simple generator:
function* exampleGenerator(i) { yield i + 1; yield i + 2; yield i + 3; } function run(iter) { let { value, done } = iter.next(); while (!done) { ({ value, done } = iter.next()); console.log(value); } } run(exampleGenerator(2));
In this example, exampleGenerator
's sole responsibility is to provide
values, while run
's responsibility is to perform side-effects that use those
values (In this case, logging them to the console). If we squint a little bit,
we can visualize run
"pulling" values out of exampleGenerator
via the call
to iter.next()
.
What would happen if we swapped those responsibilities? What if exampleGenerator
was
responsible for doing the work, and run
was responsible for providing values?
We can do this by calling iter.next()
with an argument. That argument becomes
the result of the last yield
statement that paused the generator:
function* exampleGenerator(m) { while (true) { const n = yield; console.log(n + m); } } function run(iter) { iter.next(); // Run the generator until the first `yield` statement iter.next(1); iter.next(2); iter.next(3); iter.return(); } run(exampleGenerator(2));
It's a bit weird, no? The generator became the "important" part of our code, but
we inverted control of it to the outside world by pushing values for it to use
through the next()
function call. It's turned into a sort of adding-and-logging
engine, which will happily wait around forever for its next value until we stop
it with iter.return()
.
This control-flow mechanism unlocks interesting new patterns- for instance, we can provide a value to the generator based on the last value it yielded to us:
function* smileyGenerator() { console.log(yield "HAPPY"); console.log(yield "SAD"); console.log(yield "I HAVE OTHER EMOTIONS TOO, Y'KNOW"); } function getSmiley(value) { switch (value) { case "HAPPY": { return ":)"; } case "SAD": { return ":("; } default: { return "¯\_(ツ)_/¯"; } } } function run(iter) { let smiley; // Run the generator until the first `yield` statement let { value, done } = iter.next(); while (!done) { smiley = getSmiley(value); ({ value, done } = iter.next(smiley)); } } run(smileyGenerator());
This should be starting to look suspiciously familiar if you've ever heard of the Command pattern. Module A (smileyGenerator) passes a "command object" (value) to module B (getSmiley), which fulfills that command on module A's behalf (returns a smiley).
By expanding on this theme, we can build a generator which can request both actions and data.
function* exampleGenerator() { const randomNumber = yield ["GET_RANDOM_NUMBER"]; yield ["LOG", "Here's a random number:", randomNumber]; } function performCommand(command) { const [ commandType, ...commandArgs ] = command; switch (commandType) { case "GET_RANDOM_NUMBER": { return Math.random(); } case "LOG": { return console.log(...commandArgs); } default: { throw new Error("Unknown command."); } } } function run(iter) { let command; let { value, done } = iter.next(); while (!done) { try { commandResult = performCommand(command); } catch (err) { iter.throw(err); // An error occurred! Throw it in the generator! } ({ value, done } = iter.next(commandResult)); } } run(exampleGenerator());
This example decouples behavior (exampleGenerator
) from implementation
(performCommand
) which makes testing behavior rather easy:
const iter = exampleGenerator(); let commandType, commandArgs, commandResult; [commandType, ...commandArgs] = iter.next(); assertEqual(commandType, "GET_RANDOM_NUMBER"); assertEqual(commandArgs, []); [commandType, ...commandArgs] = iter.next(5); assertEqual(commandType, "LOG"); assertEqual(commandArgs, ["Here's a random number:", 5]);
We no longer have to stub out Math.random
or console.log
- we're able to make
assertions about behavior simply by comparing values.
Now, it'd be a drag to have to add a new command every time we wanted to
introduce a new function, so let's teach performCommand
to invoke arbitrary
functions on our behalf:
function performCommand(command) { const [ commandType, ...commandArgs ] = command; switch (commandType) { case "GET_RANDOM_NUMBER": { return Math.random(); } case "LOG": { return console.log(...commandArgs); } case "CALL": { const [fn, ...fnArgs] = commandArgs; return fn.call(...fnArgs); } default: { throw new Error("Unknown command."); } } }
This completely obviates the need for "GET_RANDOM_NUMBER"
and "LOG"
.
function* exampleGenerator() { const randomValue = yield ["CALL", Math.random]; yield ["CALL", console.log, "Here's a random number:", randomValue]; }
This looks good, but we have one last problem: what if our function were asynchronus? Our driver code is synchronous, so we'll have to stretch our brains a little bit to come up with a solution. First, let's look at the business-logic code we'd like to support.
function delayedHello(name, cb) { setTimeout(() => { cb(undefined, "Hello, " + name + "!"); }, 1000); } function* exampleGenerator() { const message = yield ["CALL_ASYNC", delayedHello, 'world']; yield ["CALL", console.log, message]; }
What we're asking for here is the ability to treat delayedHello
as if it
were synchronous. We yield a "CALL_ASYNC"
command, asking the driver code to
return control to us with the resulting value once it's available. Let's see
what the supporting driver code looks like.
First, we'll stub in our "CALL_ASYNC"
command. It should look pretty similar
to the "CALL"
command, but with an additional callback parameter for the
function passed in:
function performCommand(command) { const [ commandType, ...commandArgs ] = command; switch (commandType) { case "CALL": { const [fn, ...fnArgs] = commandArgs; return fn.call(...fnArgs); } case "CALL_ASYNC": { const [fn, ...fnArgs] = commandArgs; const callback = (err, result) => { /* ??? */ }; return fn.call(...fnArgs, callback); } default: { throw new Error("Unknown command."); } } }
So, what goes in that callback? We're in a tricky situation here, because this code is synchronous, too. We've successfully pushed the problem into our driver code, but now we have to actually solve the problem.
Promises save the day! If we modify performCommand to always return a Promise, we can support both the synchronous and asynchronous use cases.
function performCommand(command) { const [ commandType, ...commandArgs ] = command; switch (commandType) { case "CALL": { const [fn, ...fnArgs] = commandArgs; const result = fn.call(...fnArgs); // Resolves immediately with the result of invoking 'fn'. return Promise.resolve(result); } case "CALL_ASYNC": { const [fn, ...fnArgs] = commandArgs; return new Promise((resolve, reject) => { // Continuation-passing style callback. If given an 'err' argument, we // reject this promise- otherwise, the function was successful and we // resolve this promise. const callback = (err, result) => ( err ? reject(err) : resolve(result) ); fn.call(...fnArgs, callback); }); } default: { return Promise.reject("Unknown command."); } } }
Now, performCommand
will consistently return a Promise, whether it's executing
synchronous or asynchronous behavior. All we have to do now is modify our run
function to work with Promises.
Here's our current implementation of run
:
function run(iter) { let command; let { value, done } = iter.next(); while (!done) { try { commandResult = performCommand(command); } catch (err) { iter.throw(err); } // Ain't gonna work! commandResult is a Promise, not a value. ({ value, done } = iter.next(commandResult)); } }
Unfortunately, we can't use that while-loop anymore, since we don't want to enter another iteration of the loop until our Promise resolves. To solve this, we transform our iteration into recursion:
function run(iter, lastResult) { // Run the generator until the next `yield` statement const { value, done } = iter.next(lastResult); // If the generator finished executing, we're done here. if (done) { return; } // Otherwise, get a Promise for the result of the next command. performCommand(value) .then( // If we successfully performed the command, recurse again. This is // the equivalent of "going back to the top of the loop". (commandResult) => run(iter, commandResult) // If the command failed, throw an error in our iterator and bail out, // ending the recursion. (err) => iter.throw(err) ); } run(exampleGenerator());
Looks good! This code would probably pass the initial smoke test, but we still have one more gotcha to handle.
If the user requests a series of synchronous function calls, this implementation
will block the JavaScript event loop because it directly makes the recursive
call to run
. Worse, if that series of synchronous function calls gets too
long, it'll blow up with the dreaded "Range Error: Maximum call stack size
exceeded" error.
Luckily, fixing this problem is straightforward: wrap the recursive call to
run
in a setTimeout
. This gives the JavaScript runtime a chance to
catch its breath and start a fresh call stack.
function run(iter, lastResult) { const { value, done } = iter.next(lastResult); if (done) { return; } performCommand(value) .then( // Schedule a call to 'run' on the next event loop, rather than calling it // directly. (commandResult) => setTimeout(run, 0, iter, commandResult), (err) => iter.throw(err) ); } run(exampleGenerator());
At this point, we've diverged substantially from the original generator/iterator model,
but in the process, we’ve arrived at an implementation of the "Command pattern" using
generators. Using this, we can write our business-logic procedures as generator
functions, which yield abstract commands to redux-saga
. redux-saga
then performs the corresponding effect and resumes the generator with the result. This is the
definition of a "saga" as implemented in redux-saga
.
Knowing this, we can port the code above with little effort. For reference, the
'cps' effect in the code below stands for 'continuation-passing style', or the
practice of calling a function that takes a callback as its last parameter, such
as the delayedHello
function we used above.
import { runSaga } from 'redux-saga'; // These functions create 'command objects' which are analogues of the ones // we implemented in the above examples. import { call, cps } from 'redux-saga/effects'; function* exampleSaga() { const message = yield cps(delayedHello, 'world'); yield call(console.log, message); } // Replaces `run` and `performCommand` runSaga(exampleSaga);
We've only scratched the surface of redux-saga
. In the next article, we'll
talk about the effects it provides beyond simple function invocation, including
Redux interaction, concurrency and control flow effects, and even saga
combinators!