Permalink
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
472 lines (311 sloc) 9.21 KB
title date layout draft tags
Making async reasonable with redux saga
2018-08-24
post
false
JavaScript
ES6

Redux Saga Logo

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 Redux Logo

  • 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 Positive

  • Fewer lines of code

  • Less indentation (avoids "callback hell")

  • Easiest to read quickly, reason about

  • Easier to debug

  • Execution stops on unhandled error

Disadvantages Negative

  • 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