Try   HackMD

Redux-Saga V.S. Redux-Observable

Redux-Saga V.S. Redux-Observable

  1. Metal Model
  2. Side by side Comparison
  3. Which one do I prefer and Why?
  4. Futher Reading and Discussion

Metal Model

Saga = Worker + Watcher

Rxjs = Epic( Type + Operators )


Saga = Worker + Watcher

import API from '...'

function* Watcher(){
    yield takeEvery('do_thing', Worker)
}

function* Worker() { 
    const users = yield API.get('/api/users')
    yield put({type:'done', users})
}

Rxjs = Epic( Type + Operators )

import API from '...'

const Epic = action$ => 
    action$
        .ofType('do_thing')
        .flatMap(()=>API.get('/api/users'))
        .map(users=>({type:'done', users}))

Side by side Comparison


Demo Part (1/3)

  1. Fetch User

  2. Fetch User (cancelable)

  3. Do three things in sequence

  4. Login, Logout, Cancel (with redux)


Fetch User from /api/users/1

Saga


import axios from 'axios' 

function* watchSaga(){
  yield takeEvery('fetch_user', fetchUser) // waiting for action (fetch_user)
}

function* fetchUser(action){
    try {
        yield put({type:'fetch_user_ing'})
        const response = yield call(axios.get,'/api/users/1')
        yield put({type:'fetch_user_done',user:response.data})
  } catch (error) {
        yield put({type:'fetch_user_error',error})
  }
}

Fetch User from /api/users/1

Rxjs

import axios from 'axios'

const fetchUserEpic = action$ => 
    action$
        .ofType('fetch_user')
        .map(()=>({type:'fetch_user_ing'}))
        .flatMap(()=>axios.get('/api/users/1'))
        .map(response=>response.data)
        .map(user=>({type:'fetch_user_done', user}))

Fetch User from /api/users/1 (cancelable)

Saga

import { take, put, call, fork, cancel } from 'redux-saga/effects'
import API from '...'

function* fetchUser() {
    yield put({type:'fetch_user_ing'})
    const user = yield call(API)
    yield put({type:'fetch_user_done', user})  
}

function* Watcher() {
    while(yield take('fetch_user')){
        const bgSyncTask = yield fork(fetchUser)
        yield take('fetch_user_cancel')        
        yield cancel(bgSyncTask)
    }
}

Fetch User from /api/users/1 (cancelable)

Rxjs

const fetchUserEpic = action$ =>
    actions$
        .ofType('fetch_user')
        .map(()=>({type:'fetch_user_ing'}))
        .flatMap(()=>{
            return Observable
                .ajax
                .get('/api/user/1')
                .map(user => ({ type: 'fetch_user_done', user }))
                .takeUntil(action$.ofType('fetch_user_cancel'))
        })

Do three things in sequence

Saga

function* worker1() { ... }
function* worker2() { ... }
function* worker3() { ... }

function* watcher() {
  const score1 = yield* worker1()
  yield put(({type:'show_score', score1})

  const score2 = yield* worker2()
  yield put(({type:'show_score', score2})

  const score3 = yield* worker3()
  yield put(({type:'show_score', score3})
}

Do three things in sequence

Rxjs

const score1Epic = action$ =>
    action$
        .ofType('score1')
        .reduce(worker1)
        .flatMap(score=>{
             return Observable.merge(
                {type:'show_score', score},
                {type:'score2'}
             )
         })

const score2Epic = action$ =>
    action$
        .ofType('score2')
        .reduce(worker2)
        .flatMap(score=>{
            return Observable.merge(
                {type:'show_score', score},
                {type:'score3'}
            )
        })

 const score3Epic = action$ =>
    action$
        .ofType('score3')
        .reduce(worker3)
        .map(score=>({type:'show_score', score}))
           

Login, token, Logout, Cancel
(with redux)

const store = {
    token: null,
    isFetching: false
}

const tokenReducer = (state=null, action) => {
    switch (action.type) {
        case 'login_success':
            return action.token
        case 'login_error':
        case 'login_cancel':
        case 'logout'
            return null
        default:
            return state
    }
}

const isFetching = (state=false, action) => {
    switch (action.type) {
        case 'login_request':
            return true
        case 'login_success':
        case 'login_error':
        case 'login_cancel':
        case 'logout'
            return false
        default:
            return state
    }
}


Login, token, Logout, Cancel

Saga

import { take, put, call, fork, cancel } from 'redux-saga/effects'
import Api from '...'


function* loginWatcher() {
    const { user, password } = yield take('login_request')
        , task = yield fork(authorize, user, password)
        , action = yield take(['logout', 'login_error'])
    
    if (action.type === 'logout') {
        yield cancel(task)
        yield put({type:'login_cancel'})
    }
  
}

function* authorize(user, password) {
    try {
        const token = yield call(Api.getUserToken, user, password)
        yield put({type: 'login_success', token})
  } catch (error) {
        yield put({type: 'login_error', error})
  }
}



Login, token, Logout, Cancel

Rxjs

const authEpic = action$ => 
    action$
        .ofType('login_request')
        .flatMap(({payload:{user,password}})=>
            Observable
                .ajax
                .get('/api/userToken', { user, password })
                .map(({token}) => ({ type: 'login_success', token }))
                .takeUntil(action$.ofType('login_cancel', 'logout'))
                .catch(error => Rx.Observable.of({type:'login_error', error}))


Demo Part 2/3


Logger

// ----- Saga ----- \\
while (true) {
    const action = yield take('*')
        , state = yield select()
console.log('action:', action)
console.log('state:', state)
}

// ----- Rxjs ----- \\
.do(value=>console.log(value))

Take latest request

// ----- Saga ----- \\
takeLatest()

// ----- Rxjs ----- \\
.switchMap()

Retry with delay (1000ms)


// ----- Saga ----- \\

... still thinking about it


// ----- Rxjs ----- \\

.retryWhen(errors=>{
    return errors.delay(1000).scan((errorCount, err)=>{
        if(errorCount < 3) return errorCount + 1
        throw err              
    }, 0)
})

Error Handling


// ----- Saga ----- \\
try {
    // ... do things 
} catch (error) {
    yield put({ type:'fetch_user_error',error })
}


// ----- Rxjs ----- \\

.catch(error => Observable.of({ type:'fetch_user_error', error }))


Running Tasks In Parallel


// ----- Saga ----- \\
import { call } from 'redux-saga/effects'

const [users, repos]  = yield [
  call(fetch, '/users'),
  call(fetch, '/repos')
]


// ----- Rxjs ----- \\
.flatMap(()=>{
	return Observable.merge(promiseA, promiseB)
})

Throttling, Debouncing, Retrying

Saga


// ---- Throttling ---- \\

yield throttle(500, 'input_change', fun)

// ---- Debouncing ---- \\

yield call(delay, 500)

// ---- Retrying ---- \\

function* retryAPI(data) {
  for(let i = 0; i < 3; i++) {
    try {
      const apiResponse = yield call(apiRequest, { data });
      return apiResponse;
    } catch(err) {
      if(i < 3) {
        yield call(delay, 2000);
      }
    }
  }
  throw new Error('API request failed');
}

Throttling, Debouncing, Retrying

Saga


// ---- Throttling ---- \\

.throtleTime(1000)

// ---- Debouncing ---- \\

.debouncing(1000)

// ---- Retrying ---- \\

.retry(3)


Part (3/3)

Test


Saga

{
  CALL: {
    fn: Api.fetch,  <<<<<<<<<<<<< make sure call with the right fn
    args: ['./products'] <<<<<<<< make sure call with the right args
  }
}

expect(
    fetchUser('api/users/1').next().value
).to.eql( 
    call(axios.get, 'api/users/1') 
)


Rxjs

// fake store ...

// ...

// ...


//   https://redux-observable.js.org/docs/recipes/WritingTests.html


Which One Do I prefer?


Coding Style

Saga

  1. Effects as data
  2. (Imperative style)
    Tell Program HOW to do things
    Take time to reason about code

Rxjs

  1. Events as data
  2. (Declarative Style)
    Tell Program WHAT things to do
    Easy to reason about code

Function Reusability

Saga

Low

Rxjs

High


Test

Saga

  • Unit Test(easy)
  • Intergration Test(easy)
  • Test might fail if the order change

Rxjs

  • Unit Test(Very easy)
  • Intergration Test(Require Mocking)
  • Test will not fail(same end result)

Learn Saga

  • Redux (1 day)
  • Generator (1 day)
  • Redux-Saga (few hours)
  • Skill is not transferable

Total Learning Time:

3~5 days


Learn Rxjs

  • Redux (1 day)
  • Rxjs (few days)
  • Functional Programming (2 days)
  • Redux-Observable (1 day)
  • Skill is transferable

Total Learing Time:

1~2 weeks


Futher Reading

merge()
map()
filter()
scan()
combineLatest()
concat()
do()
more

Visual Learning http://rxmarbles.com/


Futher Discussion

Saga = Effects as data
Rxjs = Events as data

Saga doesn't actually perform the side effects itself. Instead, the helper functions will create objects that represents the intent of side effect. Then the internal library performs it for you".

call (http.get, '/users/1') // the task

CALL: { fn: http.get,  args: ['/users/1'] } // the intent

This makes testing extremely easy, without the need for mocking.


tags: React, Redux, Redux-Saga, Redux-Observable