Making async reasonable with redux saga
Redux-saga + the Yield keyword are the most powerful tool I’ve ever seen in the JavaScript world to manage side-effects (API calls, DBs, log, …)
I created this content as part of a presentation at Kinaxis, where I’m currently working.
I hope you can learn new things and improve the reasonability of your code. =D
Agenda
-
Why redux-saga?
-
What is redux-saga?
-
What is Yield?
-
Callbacks vs. Promises vs. Yield
-
Effects
-
Channels
Why redux-saga?
-
Makes your code more reasonable
-
Easy to test (no mocks)
-
No circular dependencies
-
Ideal for common real-world applications
-
Works on both client and server
-
Large and growing contributing user base
What is redux-saga?
-
Redux middleware
-
Manages side effects (API, DB, logs, etc.)
-
Listen for actions, dispatches other actions, (using effects)
-
Maintains continuously running process called sagas
-
Uses Yield keyword
What is Yield?
-
Special keyword that can delay the execution of subsequent code
-
Only works inside generator functions
-
Works with promises and condenses code surrounding them
Callbacks vs. Promises vs. Yield
Async example with callbacks
api.get(URL, function callback(data){
// code execution resumes here
});
// code outside callback runs before callback resolution
Code tends to drift to the right with more nested callbacks. (Callback hell)
api.get(URL_A, function callback(dataA){
api.get(URL_B, function callback(dataB){
api.get(URL_C, function callback(dataC){
// ...
});
});
});
// code outside callbacks runs before callbacks resolutions
Async example with promises
api.get(URL)
.then(data => {
// code execution resumes here
});
// code after ".then()" runs before promise resolution
Code tends to grow vertically with additional “then” calls
api.get(URL_A)
.then(dataA => {
return api.get(URL_B);
})
.then(dataB => {
return api.get(URL_C);
})
.then(dataC => {
// ...
});
// code after ".then()" runs before all promises resolutions
Async example with yield
const data = yield api.get(URL);
// Execution resumes here. No code can run before promise resolution.
Code meant to be executed after call resolves can be placed on next line, as with synchronous code
No additional scope required
Code is always compact
const dataA = yield api.get(URL_A);
const dataB = yield api.get(URL_B);
const dataC = yield api.get(URL_C);
// Execution resumes here. No code can run before all promises resolutions.
Yield
Advantages
-
Fewer lines of code
-
Less indentation (avoids “callback hell”)
-
Easiest to read quickly, reason about
-
Easier to debug
-
Execution stops on unhandled error
Disadvantages
-
Only works inside Generator Functions
-
Requires additional plugins
What is a Generator Function?
-
Special Javascript function denoted by *
-
Calling function returns a generator
-
Actual code is executed by calling “next” method
-
Can “yield” multiple values
function* generateId() {
var id = 0;
while(true) {
id = id + 1;
yield id;
}
}
const gen = generateId();
gen.next().value; // 1
gen.next().value; // 2
gen.next().value; // 3
Continuously running process example
Sagas can run forever
import { delay } from "redux-saga";
function* logEachSecond() {
while(true){
yield delay(1000);
console.log("Saga loop");
}
}
Effects
functions that return a plain JavaScript object and do not perform any execution.
-
The execution is performed by the middleware during the Iteration process.
-
The middleware examines each Effect description and performs the appropriate action.
select, call and put
select: gets a value from the store
call: calls any function, most used for side-effects
put: dispatches one action to the store
import { select, call, put } from "redux-saga/effects";
import actions from "../actions";
import api from "../api";
function* getWorksheet(worksheetId) {
const settings = yield select(state => state.settings[worksheetId]);
const data = yield call(api.getWorksheet, { worksheetId, settings });
yield put(actions.setWorksheet(data));
}
Effects are objects
That is why there is no mocks.
call(api.signIn, user);
Output:
{
'@@redux-saga/IO': true,
CALL: {
context: null,
fn: [Function: signIn],
args: [ [Object] ]
}
}
How to test?
No mocks!
function* signIn(user) {
const result = yield call(api.signIn, user);
if(result.ok) {
yield put(actions.setAuthUser(result));
} else {
yield put(actions.alertError(result));
}
}
beforeEach(() => {
gen = signIn(user);
expect(gen.next().value)
.toEqual(call(api.signIn, user));
});
test("signIn ok", () => {
expect(gen.next(resultOk).value)
.toEqual(put(actions.setAuthUser(resultOK)));
});
test("signIn error", () => {
expect(gen.next(resultError).value)
.toEqual(put(actions.alertError(resultError)));
});
Handle errors with Try Catch
import { select, call, put } from "redux-saga/effects";
import actions from "../actions";
import api from "../api";
function* getWorksheet(worksheetId) {
try{
const settings = yield select(state => state.settings[worksheetId]);
const data = yield call(api.getWorksheet, { worksheetId, settings });
yield put(actions.setWorksheet(data));
} catch (error) {
yield put(actions.logError(error));
}
}
takeEvery
starts a new saga for each dispatched action.
import { takeEvery } from "redux-saga/effects";
function* handleActionA(action) {
// ...
}
function* watchActions() {
yield takeEvery('ACTION_A', handleActionA);
}
takeLatest
cancels the current Saga if it is running and starts the latest dispatched action.
import { takeLatest } from "redux-saga/effects";
function* handleActionA(action) {
// ...
}
function* watchActions() {
yield takeLatest('ACTION_A', handleActionA);
}
race
Sometimes we start multiple tasks in parallel but we don’t want to wait for all of them, we just need to get the winner.
import { race, take, put } from 'redux-saga/effects'
import { delay } from 'redux-saga'
function* fetchPostsWithTimeout() {
const [posts, timeout] = yield race([
call(fetchApi, '/posts'),
call(delay, 1000)
]);
if (posts)
put({type: 'POSTS_RECEIVED', posts});
else
put({type: 'TIMEOUT_ERROR'});
}
all
waits all calls to return.
import api from '../api';
import { all, call } from `redux-saga/effects`;
function* mySaga() {
const [customers, products] = yield all([
call(api.fetchCustomers),
call(api.fetchProducts)
]);
}
throttle
ensures that the Saga will take at most one action during each period of specified time.
function* handleInput(input) {
// ...
}
function* watchInput() {
yield throttle(500, 'INPUT_CHANGED', handleInput);
}
take
waits until it gets the desired action.
import { put, take, call } from "redux-saga/effects";
import actions from "../actions";
import api from "../api";
function* removeUser(user) {
yield put(actions.confirmRemoveUser(user));
yield take("CONFIRM_OK");
yield call(api.removeUser, user);
}
take in a loop
import { actionChannel, take, call } from "redux-saga/effects";
import api from "../api";
function* logErrors() {
while(true) {
const action = yield take("LOG_ERROR");
yield call(api.log, action);
// ...
}
}
actionChannel
creates a queue of actions, you don’t lose any action and you can process one by one.
import { actionChannel, take, call } from "redux-saga/effects";
import api from "../api";
function* logErrors() {
const channel = yield actionChannel("LOG_ERROR");
while(true) {
const action = yield take(channel);
yield call(api.log, action);
// ...
}
}
Thank you!
References + Learn more
-
Redux Saga https://app.pluralsight.com/library/courses/redux-saga
-
Advanced Redux https://app.pluralsight.com/library/courses/advanced-redux
-
Async React with Redux Saga https://egghead.io/courses/async-react-with-redux-saga
-
Redux Saga docs https://redux-saga.js.org/