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. 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, '', { 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 forks 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.