reduxとthunk, redux-saga, effectsについて

Jul 24, 2016 ( Feb 11, 2022 更新 )

モチベーション

reduxでどのように非同期処理を扱うのか、redux界隈ではいくつか方法が考えられているが、それぞれの利点・弱点を調べてみる。

redux middlewareについて復習

Middleware | Redux

redux middlewareのコンセプトは

It provides a third-party extension point between dispatching an action, and the moment it reaches the reducer.

とのこと。 redux middlewareはdispatching actionと、action objectがreducer関数に渡されるタイミングの間に存在する。

logging examples

dispatchの前後にconsole.logを使う

let action = addTodo('Use Redux')

console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())

関数に切り出す

上記のconsole.logdispatchをするところを関数に切り出す。

function dispatchAndLog(store, action) {
  console.log('dispatching', action)
  store.dispatch(action)
  console.log('next state', store.getState())
}

dispatchAndLog(store, addTodo('Use Redux'))

ただ、これではstoreがパラメータになるので使いやすいとは言えない。

dispatch関数をmonkey patchingする

reduxのstore object は、以下のいくつかの関数しかないシンプルなオブジェクト:

  • getState()
  • dispatch(action)
  • subscribe(listener)
  • replaceReducer(nextReducer)

なので、dispatch()を monkey patching すると、store.dispatch()はそのままに、自分の追加したい処理を実行できそう。(あまりいいやり方ではないにしても)

let next = store.dispatch
store.dispatch = function dispatchAndLog(action) {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}

複数の処理をmonkey patchingしたいときにはどうするか?

loggingとerror reportingの処理をmonkey patchingしたいときには以下のように書ける。

function patchStoreToAddLogging(store) {
  let next = store.dispatch
  store.dispatch = function dispatchAndLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
  }
}

function patchStoreToAddCrashReporting(store) {
  let next = store.dispatch
  store.dispatch = function dispatchAndReportErrors(action) {
    try {
      return next(action)
    } catch (err) {
      console.error('Caught an exception!', err)
      Raven.captureException(err, {
        extra: {
          action,
          state: store.getState()
        }
      })
      throw err
    }
  }
}

patchStoreToAddLogging(store)
patchStoreToAddCrashReporting(store)

monkey patchingを隠蔽する

monkey patchingな関数を「関数を返す関数」にして

function logger(store) {
  let next = store.dispatch

  // Previously:
  // store.dispatch = function dispatchAndLog(action) {

  return function dispatchAndLog(action) {
    console.log('dispatching', action)
    let result = next(action)
    console.log('next state', store.getState())
    return result
  }
}

以下のように複数のmonkey patchingな関数を順に実行するような関数をつくることができる:

function applyMiddlewareByMonkeypatching(store, middlewares) {
  middlewares = middlewares.slice()
  middlewares.reverse()

  // Transform dispatch function with each middleware.
  middlewares.forEach(middleware =>
    store.dispatch = middleware(store)
  )
}

applyMiddlewareByMonkeypatching(store, [ logger, crashReporter ])

このmonkey patchingを、処理はそのままにライブラリのなかに隠蔽することができる。 次の項目で例を挙げる。

monkey patchingを取り除く

dispatch関数をmonkey patchingする理由は、どこからでも共通の処理(monkey patchingしている関数)を呼び出せるようにするためである。

前の例で取り上げたapplyMiddlewareByMonkeypatchingのように、共通の処理をを順に行っていくやり方は以下のように書くこともできる:

const logger = store => next => action => {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}

const crashReporter = store => next => action => {
  try {
    return next(action)
  } catch (err) {
    console.error('Caught an exception!', err)
    Raven.captureException(err, {
      extra: {
        action,
        state: store.getState()
      }
    })
    throw err
  }
}

これはredux middlewareのやり方と同様のもの。 この書き方では以下のような処理の流れになる:

  • middleware関数はnext()としてdispatch関数を受け取る
  • next()を次のmiddlewareに順に渡すdispatch関数を返す

storegetState()などの関数を使えると便利なので、パラメータとしてstoreを渡せるようになっている。

middleware関数を適用するための案

前述したapplyMiddlewareByMonkeypatching()のかわりに、以下のようなapplyMiddleware(store, middleware)関数を書くことができる。

// Warning: Naïve implementation!
// That's *not* Redux API.

function applyMiddleware(store, middlewares) {
  middlewares = middlewares.slice()
  middlewares.reverse()

  let dispatch = store.dispatch
  middlewares.forEach(middleware =>
    dispatch = middleware(store)(dispatch)
  )

  return Object.assign({}, store, { dispatch })
}

store => next => action => {}の形式のmiddleware関数は、上記のようにdispatch関数をwrapすることができる。 applyMiddleware(store, middleware)の最後で、middleware関数でwrapしたdispatch関数をもつstoreを返す。

この方法は、以下の点で実際のredux middlewareのAPIと異なっている:

  • redux middlewareにはstore APIのdispatch(action)getState()しか使わせない
  • これは少しトリッキーだが、middleware関数内部でnext(action)ではなくstore.dispatch(action)を実行すると、actionはstore.dispatch()したmiddlewareを含むすべてのmiddlewareの処理を再度順に実行することになる
  • 1度だけmiddlewareが適用されるようにするには、store自体を使うのではなくcreateStore()関数を使う
    • (store, middlewares) => storeではなく、(...middlewares) => (createStore) => createStoreを使う
    • これはcreateStoreにパラメータとして渡して実行することができる(createStore | Redux

最終案: reduxでどうmiddlewareを使うか

middleware関数は以下のようになる。

const logger = store => next => action => {
  console.log('dispatching', action)
  let result = next(action)
  console.log('next state', store.getState())
  return result
}

const crashReporter = store => next => action => {
  try {
    return next(action)
  } catch (err) {
    console.error('Caught an exception!', err)
    Raven.captureException(err, {
      extra: {
        action,
        state: store.getState()
      }
    })
    throw err
  }
}

これをcreateStore関数に、applymiddleware関数を使ってenhancerとして渡す。

import { createStore, combineReducers, applyMiddleware } from 'redux'

let todoApp = combineReducers(reducers)
let store = createStore(
  todoApp,
  // applyMiddleware() tells createStore() how to handle middleware
  applyMiddleware(logger, crashReporter)
)

applyMiddleware(…middlewares)関数は何をしているのか

middlewareのポイントは以下。

  • 合成可能(composable)なこと
  • middlewaresは、自分以外のmiddlewareが何をしているか知らなくてもよい

middlewaresのインターフェース

以下がmiddleware関数のインターフェースとなる。

({ getState, dispatch }) => next => action

thunk middlewareの例

かなり簡単なmiddlewareだったので引用する。

  • dispatch()のパラメータとしてaction objectではなく関数を受け取る
  • 受け取った関数をreducer関数が実行される前に実行
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

redux Thunk

  • redux Thunk middlewareは、dispatchするときのパラメータをactionオブジェクトだけではなく、関数をも受け取れるようにする
    • このdispatchするときに渡す関数は、dispatchgetState関数を引数として受け取れる

サンプルコードをそのまま見ていくとわかりやすい。 componentではdispatch(actionCreator())を実行する。 actionCreatorは、dispatchgetState関数を引数に取る関数を返す。redux-thunk middlewareはこのdispatchの使い方を許容する。

return (dispatch, getState) => {
  fetchPromise.then((res) => {
    dispatch(actionCreatoer(res));
  });
}

この関数の中で、fetchなどの非同期処理を行うPromiseのthenを使って、任意のタイミングでViewに状態を反映させるためのactionをactionCreator関数で作成し、actionオブジェクトをdispatchする。

thunkとは

Thunk - Wikipedia, the free encyclopedia

現時点(2016/07/24)では英語版, ドイツ語版, スペイン語版しかページがないため、わりと新しい用語?なのだと思われる。 thunkとはsubroutineのサポートをするために、(殆どの場合は)自動的につくられるsubroutineのことだそう。

下記の例のuse関数のようなものがthunk関数の例、とのこと。

class A {
    int value;
    virtual int access() { return this->value; }
};
class B {
    int value;
    virtual int access() { return this->value; }
};
class C : public A, public B {
    int better_value;
    virtual int access() { return this->better_value; }
};

int use(B *b) {
    return b->access();
}

// ...
B someB;
use(&someB);
C someC;
use(&someC);

redux-saga…の前に、redux-thunkの問題点

yelouafi/redux-saga: An alternative side effect model for Redux apps

You might’ve used redux-thunk before to handle your data fetching. Contrary to redux thunk, you don’t end up in callback hell, you can test your asynchronous flows easily and your actions stay pure.

redux-sagaのREADMEによると、redux-thunkには以下の2点の問題があるという:

  • callback hell
    • あくまで予想だが、複数のfetchを行ったり、複雑なロジックが絡んでくるとわかりにくくなるということなのだと思う
  • テストが難しい
  • action creatorがaction objectを返す役割ではなくなる?(your actions stay pureのところ)

callback hell

テストが難しい

たしかに下記のようなthunk関数をテストするときに、Promiseをスタブにしないといけないので面倒だとは思った。

return (dispatch, getState) => {
  fetchPromise.then((res) => {
    dispatch(actionCreatoer(res));
  });
}

action creatorがaction objectを返す役割ではなくなる

非同期処理をreducerに入れるか、action cratorに入れるか?という論点がある。 私はは非同期処理はaction creatorに入れたほうがよいと思う。 そのため、redux-sagaのREADMEが言っているようなaction creatorがpure action creatorではないことに特に問題を感じなかった。

理由は、reducerはViewのための (curentState) => newState にしたほうがすっきりする気がするため。 画面表示に用いるためのデータの構造と、fetchするときにもらってくるデータの構造は異なっている場合がある。 reducerは画面に表示させるためのデータ(=reduxのstate)の生成に集中したほうがわかりやすい。

redux-saga

redux-effects

redux-effects/redux-effects

Return to top