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.