JavaScript Power Tools: redux-sagaJavaScriptPowerTools:redux-sagaJavaScriptPowerTools:redux-saga
Matt Hink
10 May 2017
Share
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;}functionrun(iter){let{ value, done }= iter.next();while(!done){({ value, done }= iter.next());console.log(value);}}run(exampleGenerator(2));
js
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);}}functionrun(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));
js
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");}functiongetSmiley(value){switch(value){case"HAPPY":{return":)";}case"SAD":{return":(";}default:{return"¯\_(ツ)_/¯";}}}functionrun(iter){let smiley;// Run the generator until the first `yield` statementlet{ value, done }= iter.next();while(!done){ smiley =getSmiley(value);({ value, done }= iter.next(smiley));}}run(smileyGenerator());
js
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];}functionperformCommand(command){const[ commandType,...commandArgs ]= command;switch(commandType){case"GET_RANDOM_NUMBER":{returnMath.random();}case"LOG":{returnconsole.log(...commandArgs);}default:{thrownewError("Unknown command.");}}}functionrun(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());
js
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]);
js
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:
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];}
js
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.
functiondelayedHello(name, cb){setTimeout(()=>{cb(undefined,"Hello, "+ name +"!");},1000);}function*exampleGenerator(){const message =yield["CALL_ASYNC", delayedHello,'world'];yield["CALL",console.log, message];}
js
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:
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.
functionperformCommand(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'.returnPromise.resolve(result);}case"CALL_ASYNC":{const[fn,...fnArgs]= commandArgs;returnnewPromise((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.constcallback=(err, result)=>( err ?reject(err):resolve(result)); fn.call(...fnArgs, callback);});}default:{returnPromise.reject("Unknown command.");}}}
js
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:
functionrun(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));}}
js
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:
functionrun(iter, lastResult){// Run the generator until the next `yield` statementconst{ 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());
js
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.
functionrun(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());
js
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 =yieldcps(delayedHello,'world');yieldcall(console.log, message);}// Replaces `run` and `performCommand`runSaga(exampleSaga);
js
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!