JavaScript Power Tools: redux-saga

May 10, 2017

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-sagathen 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!

Related Posts

Javascript Power Tools Part III: Real-world redux-saga Patterns

June 7, 2017
In the past two articles, we've talked a lot about redux-saga in the abstract, without much concern for real-world applications. Now that we’re equipped with new knowledge, we're ready to jump in and start putting the pieces back together. First, we'll take a look at a pattern...

JavaScript Power Tools Part II: Composition Patterns in redux-saga

May 17, 2017
In the last article, we investigated redux-saga's approach to the Saga pattern in JavaScript. Specifically, the concept of a generator function that yields command objects, which describe the effects we want to happen, and rely on an external driver to actually perform those effects...

What the Hex?

October 24, 2022
If you’re a designer or frontend developer, chances are you’ve happened upon hex color codes (such as `#ff6d91`). Have you ever wondered what the hex you’re looking at when working with hex color codes? In this post we’re going to break down these hex color codes and how they relate to RGB colors.