The Redux Saga Black Box

June 06, 2019

I’ve recently been learning React and Redux Sagas for a new project I’m joining. It’s been fun transitioning from Angular and learning what project organization looks like without an opinionated framework.

As I looked through the code I started to notice that yield is used a lot to generate values and pass those values to the Redux store using redux-saga. I haven’t worked with yield yet so I needed to do some research.

What follows is an exploration and some findings during my time researching. Feel free to skip around to what’s interesting to you.

Learning to use yield

Functions can use yield if they are specified as generators using a * in between their name and the function keyword:

function* getName() {
  yield "Ben Hofferber"
}

Anything can be yield-ed to be added to the generated values that are output from the generator function. To get at these values we can use .next().

const name = getName()
name.next() // { value: 'Ben Hofferber', done: false}
name.next() // { value: undefined, done: true}

Using .next() we can grab each value from the generator. When there are no more values it will indicate, using done, that it is finished. In the example, the function returns undefined indicating that the generator is not actually complete until after the last yield-ed value.

function* getName() {
  yield "Welcome"
  return "Ben Hofferber"
}

const name = getName()
name.next() // { value: 'Welcome', done: false}
name.next() // { value: 'Ben Hofferber', done: true}
name.next() // { value: undefined, done: true}

If a value is returned after a yield-ed value, that value will also be passed as a result of the generator without having to use yield. The returned value also indicates that the stream has completed (look at how early done has changed to true).

Generators return an iterable. We’ve used .next() to traverse it, but it’s also possible to use a for-loop to traverse the values:

for (let value of getName()) {
  console.log("-> ", value) // -> Welcome
}

Notice that "Welcome" is the only value that is iterated over. When a value is returned from a generator, it isn’t included in the iterated results. I bet this is the cause of a bug or two


Passing Values to a Generator

Great, so generators give us an iterable list of values back that can be manually traversed or programatically iterated over in a for-loop. Pretty cool.

But, did you know you can also pass values into a next() iteration in order to pass values to output from yield values?

function* questions() {
  let name = yield "What is your name?"
  return `Hi ${name}`
}

const interface = questions()
console.log(interface.next())
// { value: 'What is your name?', done: false }
console.log(interface.next("Ben"))
// { value: 'Hi Ben', done: true }

This is pretty cool, but also strange. I wouldn’t expect that yield-ing a result would also open an interface where I could receive values from the consumer traversing my generated values. It makes for an interesting application when looping over the iterations.

function* loopQuestion() {
  let answer
  do {
    if (answer) {
      yield answer * 2
    }
    answer = parseInt(yield `Enter number to double`)
  } while (typeof answer === "number" && !isNaN(answer))
}

let loop = loopQuestion()
console.log(loop.next())
// { value: 'Enter number to double', done: false }
console.log(loop.next(2))
// { value: 4, done: false }
console.log(loop.next())
// { value: 'Enter number to double', done: false }
console.log(loop.next(10))
// { value: 20, done: false }
console.log(loop.next())
// { value: 'Enter number to double', done: false }
console.log(loop.next())
// { value: undefined, done: true }

Generating a Generator

So we can yield anything inside of a generator. But what happens when we yield a generator? Well, we actually need to use a new operator yield*

function* contributors() {
  yield "John"
  yield "Zena"
  yield "Jerry"
}

function* getCredits() {
  yield "Actors:"
  yield* contributors()
}

const creditsIter = getCredits()
console.log(creditsIter.next())
// { value: 'Actors:', done: false }
console.log(creditsIter.next())
// { value: 'John', done: false }
console.log(creditsIter.next())
// { value: 'Zena', done: false }
console.log(creditsIter.next())
// { value: 'Jerry', done: false }
console.log(creditsIter.next())
// { value: undefined, done: true }

The handy yield* operation is used to delegate to another generator [see docs]. This has the effect of basically being able to nest generator inside of each other. It exposes it’s yield methods to the callee and operates as if it was the outer method.

Now things get even more interesting when we return a value from an underlying generator:

function* contributors() {
  yield "John"
  yield "Zena"
  yield "Jerry"
  return 3
}

function* getCredits() {
  yield "Actors:"
  const count = yield* contributors()
  console.log(count) // 3
}
// below method calls output exactly the same

We’re actually able to yield out values and return a value to the calling generator that doesn’t impact the end generated values! Nifty for when an isolated sub-process within another generator needs to output a value at the end of it’s process.

Redux Saga Ecosystem

Redux Saga basically uses yield to do all things redux. After setting up the middleware everything looks like any other redux setup.

import createSagaMiddleware from "redux-saga"

const sagaMiddleware = createSagaMiddleware()
const store = createStore(reducer, applyMiddleware(sagaMiddleware))
sagaMiddleware.run(helloSaga)

The difference is the sagas used to process side-effects based on the action events fired. The mechanic Redux Saga uses is yield-ing values to the middleware that responds to those values.

For example, takeEvery is used to listen to events and call a generator function with the action object:

import { put, takeEvery } from "redux-saga/effects"

function* changeName() {
  yield takeEvery("CHANGE_NAME", changeNameSaga)
}

function* changeNameSaga(action) {
  yield put({ type: "SET_NAME", payload: "Ben" })
}

This pattern of using function calls on saga operators is common in Redux Saga as it enables better testability and readability with the library. put is another function that is used when we want to pass a value up to Redux Saga that should be output as an action.

Want to make an API call to get data and yield a result when we get something back? Easy, we can just yield the promise:

function* changeNameSaga(action) {
  yield NameService.getName(action.payload)
    .then(name => put({ type: 'SET_NAME', payload: name });
}

^ This is the bone I have to pick with Redux Saga.

There’s a lot more going on with Promises and I don’t want to repeat the great documentation that has already been created for Redux Sagas. Just go check things out there if you want to learn more.

The Problem with the Saga Interface

Let’s review this:

function* changeNameSaga(action) {
  yield NameService.getName(action.payload).then(name =>
    put({ type: "SET_NAME", payload: name })
  )
}

If we wanted to unit test this function, we really can’t directly. This function operates in the Saga environment and this generator really just returns a Promise. Saga does the work to listen to the Promise resolution and output an action. This function cannot not work in isolation without setting up a Saga environment.

The solution given for this is to separate the two parts and always have functions export the ReduxSaga calls which we showed earlier:

function* changeNameSaga(action) {
  yield call(NameService.getName, action.payload);
}

// NameService
getName(payload) {
  return ApiCallLogic(payload)
    .then(name => put({ type: 'SET_NAME', payload: name }));
}

Now there’s Redux Saga code across multiple functions and potentially services rather than being contained. The NameService now also needs to know that it outputs a Redux Saga action. We’re effectively spreading the black box across our entire state and request layers.

I use Quokka which provides a live scratchpad for me to explore code easily. Setting up and testing these functions is really difficult because I need a real runtime to actually get results about what will happen during a genarator’s runtime. I haven’t even tried to get this setup because I just don’t see the value in it. That doesn’t make me want to even explore this saga environment at all.

Let me show another example:

function* changeNameSaga(action) {
  yield put({ type: "START_LOADING" })
  yield setNameProcess(action)
  yield put({ type: "STOP_LOADING", payload: name })
}

function* setNameProcess(action) {
  /* generator */
}

Remember when I talked about Generating a Generator? That’s actually what’s happening here, except that we don’t actually need to use the yield* operator to yeild a generator within Sagas. Sagas will actually just handle generators for us. Yet again, this makes testing more complicated.

While it looks helpful, being able to yield pretty much anything, it comes at this cognative cost that we need to be aware of sagas and keep in mind these same usages won’t work in other places. The saga ecosystem makes testing difficult as a full environment is needed to really test the functionality. Interoporating with other redux libraries handling other side-effects is more challenging because sagas gobbles up many types of objects. Finally, teaching to others is more difficult because learning yeild doesn’t really help that much when it comes to learning sagas. They’re their own thing.

My preferred alternative is redux-observable which uses rxjs observables. While there is a lot to learn and many ways to mess up Observables, I believe the code is easier to test, reason about, and teach to others.