JavaScript Power Tools Part II: Composition Patterns in redux-saga
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. We observed that such a
generator function is called a 'saga'.
Now that we understand the mechanism of action, we can start looking at some of
the ways redux-saga
allows us to compose sagas.
When I say, "compose sagas," I’m referring to the different ways to start a saga from within another one. Why do I say "start" instead of "call"? Because sagas start making much more sense when you think of them as subprograms rather than as fancy patterns for constructing functions.
First, let's go back to what we learned in the last article - we’ll begin by invoking an async function from within a saga.
function* serverHello(name) { const response = yield call(fetch, 'example.com', { method: 'POST', body: name }); const text = yield call([response, response.text]); return text; } function* rootSaga() { const result = yield call(serverHello, "world"); yield call(console.log, result); }
In serverHello
, we're making two asynchronous function calls: the first
invokes fetch
and waits for a response to become available, and the second
unwraps the response body text. In each case, we say that serverHello
is
blocking on the result of the asynchronous function.
By the same token, rootSaga
is blocking on the result of another saga, serverHello
. It won't continue executing until the return value of
serverHello
is available.
In some cases, this is a good thing. If we need to make two API calls, and the order matters, this forces them to occur in a specific order.
function* rootSaga() { // Blocked until first serverHello finishes const result0 = yield call(serverHello, 'world'); // blocked until second serverHello finishes const result1 = yield call(serverHello, 'Matt'); yield call(console.log, result0); yield call(console.log, result1); }
However, in many cases, rootSaga
might not need the result right away and
could be doing other work in the meantime.
function* rootSaga() { // Returns immediately with a Task object const task = yield spawn(serverHello, 'world'); // Perform an effect in the meantime yield call(console.log, "waiting on server result..."); // Block on the result of serverHello const result = yield join(task); // Use the result of serverHello yield call(console.log, result); }
Here, we use the non-blocking spawn
effect to tell redux-saga
that it should start the child saga, but resume rootSaga
immediately. In this
case, the return value of the spawn
effect is not the result of
serverHello
, but rather a "Task object" which acts as a handle to
serverHello
. rootSaga
is free to continue execution until we decide we
actually need a result. At this point, we yield join
with the Task object,
which instructs redux-saga
to wait for the associated saga (serverHello
) to
finish before resuming rootSaga
. The result of serverHello
then becomes the
return value of join
.
With these two primitives, we can reconstruct our previous blocking calls.
function* rootSaga() { const task0 = yield spawn(serverHello, 'world'); const result0 = yield join(task0); const task1 = yield spawn(serverHello, 'Matt'); const result1 = yield join(task1); yield call(console.log, result0); yield call(console.log, result1); }
We wouldn't write "real code" this way, but it's useful to realize
that a series of call
effects can be rewritten as a series of spawn/join
pairs.
What if we wanted to run those effects in parallel, rather than in a series?
function* rootSaga() { const task0 = yield spawn(serverHello, 'world'); const task1 = yield spawn(serverHello, 'Matt'); const [result0, result1] = yield join(task0, task1); yield call(console.log, result0); yield call(console.log, result1); }
We begin by starting both of our child sagas and saving a reference to each one's Task object.
Then, by yielding a join
effect with multiple Task objects, we wait for both
to complete before resuming. The return value of join
will become an array
containing each child saga's result.
But what if we don't care about the return value of those child sagas? For instance, what if we only care that a POST to the server finished?
function* rootSaga() { const task0 = yield spawn(serverHello, 'world'); const task1 = yield spawn(serverHello, 'Matt'); yield join(task0, task1); }
In this case, the join
effect starts to look like an afterthought,
that will trip us up if we don't remember to include it.
In many cases, we'll want a saga to kick off a bunch of non-blocking
effects and then wait for them to finish before returning. In this case,
we'd use the fork
effect, which creates an attached Task rather than an
unattached Task. The difference:
- A parent saga that
fork
s a child saga will wait for its child to complete before completing itself. - If a parent saga is cancelled before its child saga finishes executing, the child saga will be cancelled as well.
function* rootSagaWithSpawn() { const task0 = yield spawn(serverHello, 'world'); const task1 = yield spawn(serverHello, 'Matt'); yield join(task0, task1); } function* rootSagaWithFork() { yield fork(serverHello, 'world'); yield fork(serverHello, 'Matt'); }
This example is better, but it still seems a bit... magical. After all, we're relying on
the implicit behavior of the fork
to make sure both child sagas complete before finishing the execution itself. Moreover, starting up child sagas in parallel is a common pattern, so writing an entire saga to do it seems excessive. Because of this,
redux-saga
provides the all
effect, which takes an array of blocking
effects and waits for all of them to complete before resuming with all results.
function* rootSaga() { const [ result0, result1, ] = yield all([ call(serverHello, 'world'), call(serverHello, 'Matt'), ]); yield call(console.log, result0); yield call(console.log, result1); }
To bring things full circle, this is more-or-less equivalent to
Promise.all
.
Now that we have a frame of reference for thinking about concurrency in redux-saga
, we can take a look at the kinds of things we can build, which will be the topic of the next article in this series.