I’m using react, redux and redux-thunk in one of my learning projects and recently I wanted to try redux-saga and see in practice the advantages, if any, of using it instead of redux-thunk. I started with saga tutorial and I got confused about how generators are used to do async operations. Instead of jumping ahead and replace redux-thunk with redux-saga, I thought that it is much better to understand how generators can be used with promises without any other helper library.
In my learning project I have multiple branches and one of them uses only react with promises. A sample async code of one my components looks like
1 2 3 4 5 6 7 |
onAddDateReadingSessionClick() { validateDateReadingSession(this.state.dateReadingSession) .then(() => createDateReadingSession(this.props.bookUuid, this.state.currentReadingSession.uuid, this.state.dateReadingSession)) .then(() => this.successOnAddDateReadingSession()) .catch(error => this.errorOnApiOperation(error)); } |
Both validateDateReadingSession
and createDateReadingSession
return promises so we can chain them with then
and catch
methods. How can we use generators to simplify this code? Before answering to this question we need to take a closer look on how generators work.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
function *generateEvenNumbers() { yield 0; yield 2; yield 4; yield 6; yield 8; yield 10; } let evenNumbers = generateEvenNumbers(); console.log(evenNumbers.next()); //>{value: 0, done: false} console.log(evenNumbers.next()); //>{value: 2, done: false} console.log(evenNumbers.next()); //>{value: 4, done: false} console.log(evenNumbers.next()); //>{value: 6, done: false} console.log(evenNumbers.next()); //>{value: 8, done: false} console.log(evenNumbers.next()); //>{value: 10, done: false} console.log(evenNumbers.next()); //>{value: undefined, done: true} |
In this example we have a generator that return the first six even numbers. When we call the generator with
1 2 |
let evenNumbers = generateEvenNumbers(); |
we obtain an iterator instance on which we can call the next
method. At this moment the code inside the generator was not executed yet. The first call to the next
method
1 2 |
console.log(evenNumbers.next()); |
causes the code inside the generator to run up to the first yield
statement
1 2 |
yield 0; |
and that value is the result of the next
method
1 2 |
//>{value: 0, done: false} |
The second call to the next
method resumes the execution inside the generator and continues up to the next yield
statement
1 2 |
yield 2; |
and that value is the result of the next
method
1 2 |
//>{value: 2, done: false} |
This continues up to the last yield
statement and after that each call to the next
method will return
1 2 |
//>{value: undefined, done: true} |
which states that there is no point to continue since we retrieved all possible values.
At this moment we can send values out of the generator and then wait for the next next
method call but we also need to receive values in and to signal errors when execution resumes to be able to fully support async operations.
Sending values in the generator can be done by passing a value parameter to the next method and that value will be the result of the previous yield
operation where execution resumed.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
function *generateEvenNumbers() { let zero = yield 0; let two = yield zero + 2; let four = yield two + 2; let six = yield four + 2; let eight = yield six + 2; yield eight + 2; } let evenNumbers = generateEvenNumbers(); let result = evenNumbers.next(); console.log(result); //>{value: 0, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 2, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 4, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 6, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 8, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 10, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: undefined, done: true} |
The first call to the next
method
1 2 |
let result = evenNumbers.next(); |
causes the code inside the generator to run up to the first yield
statement
1 2 |
let zero = yield 0; |
the returned value is the result of the next
method and will be assigned to result
variable.
1 2 3 |
console.log(result); //>{value: 0, done: false} |
The second call to the next
method
1 2 |
result = evenNumbers.next(result.value); |
resumes the execution inside the generator with the value returned earlier, variable zero
is initialized with the value passed in and the next yield
statement sends a new value out
1 2 |
let two = yield zero + 2; |
which is the result of the next
method
1 2 3 |
console.log(result); //>{value: 2, done: false} |
This continues up to the last yield
statement like in the previous execution flow. Because the value passed in the generator replaces the value of the previous yield
statement, there is no point to pass in a value for the first next
method.
We have a way now to pass values in the generator when execution resumes, we need to be able to signal errors too. This is done by calling throw
generator method. If in the previous example we do something like
1 2 |
evenNumbers.throw('a new error condition'); |
execution will abruptly end with that error
1 2 |
//>VM4027:1 Uncaught a new error condition |
One solution is to use try / catch
in the generator and decide what to do next
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
function *generateEvenNumbers() { let zero; try { zero = yield 0; } catch(error) { console.log(error); zero = 0; } let two = yield zero + 2; let four = yield two + 2; let six = yield four + 2; let eight = yield six + 2; yield eight + 2; } let evenNumbers = generateEvenNumbers(); let result = evenNumbers.next(); console.log(result); //>{value: 0, done: false} result = evenNumbers.throw("Something bad happened along the way"); console.log(result); //>Something bad happened along the way //>{value: 2, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 4, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 6, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 8, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: 10, done: false} result = evenNumbers.next(result.value); console.log(result); //>{value: undefined, done: true} |
The first call to the next
method
1 2 |
let result = evenNumbers.next(); |
causes the code inside the generator to run up to the first yield
statement
1 2 |
let zero = yield 0; |
the returned value is the result of the next
method and will be assigned to result
variable.
1 2 3 |
console.log(result); //>{value: 0, done: false} |
Now we don’t use next
method but instead we signal an error with throw
method
1 2 |
result = evenNumbers.throw("Something bad happened along the way"); |
which resumes the execution inside the generator with the signalled error, that error is caught and logged and we start over with a new value for zero
variable. Execution continues up to the next yield
statement
1 2 3 4 5 6 7 8 |
try { zero = yield 0; } catch(error) { console.log(error); zero = 0; } let two = yield zero + 2; |
which is the result of the next
method
1 2 3 |
console.log(result); //>{value: 2, done: false} |
This continues up to the last yield
statement like in the previous execution flows.
We have everything we need now, let’s see how we can change bellow code
1 2 3 4 5 6 7 |
onAddDateReadingSessionClick() { validateDateReadingSession(this.state.dateReadingSession) .then(() => createDateReadingSession(this.props.bookUuid, this.state.currentReadingSession.uuid, this.state.dateReadingSession)) .then(() => this.successOnAddDateReadingSession()) .catch(error => this.errorOnApiOperation(error)); } |
to benefit from the usage of generators. As I mentioned before validateDateReadingSession
and createDateReadingSession
methods return promises. If we want to avoid the usage of callbacks in onAddDateReadingSessionClick
method, we have to transform it in a generator and yield
the promises
1 2 3 4 5 6 7 8 9 10 11 12 |
*onAddDateReadingSessionClick() { try { yield validateDateReadingSession(this.state.dateReadingSession); yield createDateReadingSession(this.props.bookUuid, this.state.currentReadingSession.uuid, this.state.dateReadingSession); this.successOnAddDateReadingSession(); } catch(error) { this.errorOnApiOperation(error); } } |
Note that there is no function
statement in front of onAddDateReadingSessionClick
because it is part of a component.
We broke the execution flow by yielding a promise but this is not enough, we need another piece of code that sends back the values in and resumes the execution of the generator
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
export function run(generator, ...params) { const iterator = generator(...params); iterate(iterator.next()); function iterate(result) { if(!result.done) { const promise = result.value; promise .then(response => iterate(iterator.next(response ? response.data : undefined))) .catch(error => iterator.throw(error)); } } } |
and a way to couple the runner with the generator
1 2 3 4 5 6 7 8 |
<InputDateReadingSessionComponent operation={this.state.operation} dateReadingSession={this.state.dateReadingSession} onInputChange={this.onInputChange} onAddButtonClick={() => run(this.onAddDateReadingSessionClick)} onUpdateButtonClick={() => run(this.onUpdateDateReadingSessionClick)}/> |
The execution flow is as follow:
onAddButtonClick
is triggered and the code inside it’s event handler runsrun(this.onAddDateReadingSessionClick)
- with
const iterator = generator(...params);
functions generator is called with provided arguments iterate(iterator.next());
does the followingiterator.next()
is called firstvalidateDateReadingSession(this.state.dateReadingSession)
is executed and the response (a promise), is yielded.- iterate method is called with that promise and then it chains it’s
then
orcatch
methods. - if the promise is resolved, we are calling the
next
method and we pass in the response of the promise. - if the promise is rejected, we are signalling the error by calling the
throw
method and the error will be caught by thecatch
block.
- The flow repeats until there are no more
yield
statements.
This generator does not use the result of the promise. Bellow is another example that does this
1 2 3 4 5 6 7 8 9 |
*retrieveCurrentReadingSession() { try { const currentReadingSession = yield fetchCurrentReadingSession(this.props.bookUuid); this.successOnRetrieveCurrentReadingSession(currentReadingSession); } catch(error) { this.errorOnApiOperation(error); } } |
I do not know if this way of implementing really pays off, I find it more a matter of preference. At least java programmers will be more comfortable with this code.