React 復習メモ(その 4)

RxJS + Redux-Observable を試す

Redux で非同期 Action を捌く実験の続き。 今回は新たなアプローチとして RxJS を試してみる。

動機

Using socket.io in React-Redux app to handle real-time data.」という記事の最後(結論部分)に、こんな記述を見つけた。

This way of handling real-time data using redux-thunk may be okay for a small app which doesn’t require lot of complexities. But I would encourage you to go through how to use redux-observables and RxJS, if you plan to build a real time app to handle multiple observables and to chain/merge them.

リアルタイムデータを Redux-Thunk で捌く方法は、さして複雑ではない小規模のアプリならばべつだん問題はない。しかし、複数の Observable に対して chain / merge を扱うようなリアルタイムアプリを構築しようとするなら、Redux-ObservableRxJS の使い方を理解することを勧める。──ということらしい。

面白そうなので、ちょっとだけかじる。

前回の復習

前回のエントリに書いた Redux-Thunk 使用例のサンプルコードが今回の出発点である。 こいつを Redux-Observable 版に書き換えてみよう。

tercel-s.hatenablog.jp

念のため(というか字数稼ぎのため)前回のサンプルを再掲する。

src/App.js
import React from 'react'
import { connect } from 'react-redux'

// Action Creators
const ActionType = {
  PROCESSING: 'PROCESSING',
  INCREMENT_COUNTER: 'INCREMENT_COUNTER',
  DECREMENT_COUNTER: 'DECREMENT_COUNTER'
}

const processing = () => ({ type: ActionType.PROCESSING })      // 処理中
const increment  = () => ({ type: ActionType.INCREMENT_COUNTER })// カウントアップ
const decrement  = () => ({ type: ActionType.DECREMENT_COUNTER })// カウントダウン

const incrementAsync = () =>  // ★ カウントアップ(非同期)
  dispatch => {
    dispatch(processing())    // processing action を dispatch
    setTimeout(() => {
      dispatch(increment())   // 500ミリ秒後に increment action を dispatch
    }, 500)
  }


// Reducer
const INITIAL_STATE = { counter: 0, processing: false }
export function counter(state = INITIAL_STATE, action) {
  switch (action.type) {
    case ActionType.PROCESSING:            // 処理中
      return Object.assign({}, state,
        { processing: true })
    case ActionType.INCREMENT_COUNTER:     // カウントアップ
      return Object.assign({}, state,
        {
          counter: state.counter + 1,
          processing: false
        })
    case ActionType.DECREMENT_COUNTER:     // カウントダウン
      return Object.assign({}, state,
        { counter: state.counter - 1 })
    default:
      return state
  }
}


// Reactコンポーネント
let App = (state) => {
  const dispatch = state.dispatch
  return (
    <div>
      <div>{state.processing ?
        '処理中' : state.counter}</div>

      {/* 処理中の場合はボタンを押せなくする */}
      <button
        disabled={state.processing}
        onClick={() =>
          dispatch(incrementAsync())}>+</button>
      <button
        disabled={state.processing}
        onClick={() =>
          dispatch(decrement())}>-</button>
    </div>
  )
}


// ReactとReduxの接続
function mapStateToProps(state) { return state }
App = connect(mapStateToProps)(App)

export default App
src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware} from 'redux'
import thunk from 'redux-thunk'
import App, { counter } from './App'

import './index.css'
import registerServiceWorker from './registerServiceWorker'

const store = createStore(counter, applyMiddleware(thunk))

ReactDOM.render(
  <Provider store={store}><App /></Provider>,
  document.getElementById('root')
)

registerServiceWorker()

RxJS + Redux-Observable で書き換える

Redux-Observable を使えば、非同期 Action を、Rx のお作法で書けるらしい。 何はともあれ試す。

$ npm i --save rxjs redux-observable
src/App.js

実際に前述の App.js を書き換えた結果が以下。 直感的な説明(?)になるが、epicの中で Stream として流れてきた Action を RxJS のオペレータで捌く。

僕は躓いたけど、冒頭で必ず rxjs を import すること。 さもないと、 TypeError: action$.ofType(...).delay is not a function 的なエラーが発生してしまう(参考)。 一応、公式にも書いてある。

IMPORTANT: redux-observable does not add any of the RxJS operators to the Observable.prototype so you will need to import the ones you use or import all of them in your entry file.

Because there are many ways to add them, our examples will not include any imports. If you want to add every operator, put import 'rxjs'; in your entry index.js.

import 'rxjs'
import React from 'react'
import { connect } from 'react-redux'

// Action Creators
const ActionType = {
  PROCESSING: 'PROCESSING',
  INCREMENT_COUNTER: 'INCREMENT_COUNTER',
  DECREMENT_COUNTER: 'DECREMENT_COUNTER'
}

const incrementAsync = () => ({ type: ActionType.PROCESSING })       // 処理中
const increment      = () => ({ type: ActionType.INCREMENT_COUNTER })// カウントアップ
const decrement      = () => ({ type: ActionType.DECREMENT_COUNTER })// カウントダウン

export const epic = action$ =>          // ★ カウントアップ(非同期)
  action$.ofType(ActionType.PROCESSING) // processing action が dispatch されたとき
    .delay(500)                         // 500ミリ秒待ってから
    .mapTo(increment())                 // increment action に変換


// Reducer
const INITIAL_STATE = { counter: 0, processing: false }
export function counter(state = INITIAL_STATE, action) {
  switch (action.type) {
    case ActionType.PROCESSING:            // 処理中
      return Object.assign({}, state,
        { processing: true })
    case ActionType.INCREMENT_COUNTER:     // カウントアップ
      return Object.assign({}, state,
        {
          counter: state.counter + 1,
          processing: false
        })
    case ActionType.DECREMENT_COUNTER:     // カウントダウン
      return Object.assign({}, state,
        { counter: state.counter - 1 })
    default:
      return state
  }
}


// Reactコンポーネント
let App = (state) => {
  const dispatch = state.dispatch
  return (
    <div>
      <div>{state.processing ?
        '処理中' : state.counter}</div>

      {/* 処理中の場合はボタンを押せなくする */}
      <button
        disabled={state.processing}
        onClick={() =>
          dispatch(incrementAsync())}>+</button>
      <button
        disabled={state.processing}
        onClick={() =>
          dispatch(decrement())}>-</button>
    </div>
  )
}


// ReactとReduxの接続
function mapStateToProps(state) { return state }
App = connect(mapStateToProps)(App)

export default App

ちなみに上記コードのうち、epic の宣言文の一部である以下の箇所

action$.ofType(ActionType.PROCESSING)

は、このように書き換えることもできる。

action$.filter(action => action.type === ActionType.PROCESSING)

それ以外の Reducer や View は変更点なし。

src/index.js

続いて、index.js を見てみよう。

コード中、随所に登場する epic という単語の適切な訳語がいまいちピンとこないが、公式のドキュメントによると、epic とはすなわち redux-observable の コアプリミティヴ らしい。なんじゃそりゃ。

An Epic is the core primitive of redux-observable.

import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware} from 'redux'
import { createEpicMiddleware } from 'redux-observable';
import App, { epic, counter } from './App'

import './index.css'
import registerServiceWorker from './registerServiceWorker'

const epicMiddleware = createEpicMiddleware(epic)
const store = createStore(counter, applyMiddleware(epicMiddleware))

ReactDOM.render(
  <Provider store={store}><App /></Provider>,
  document.getElementById('root')
)

registerServiceWorker()

Qiita のこの記事にもあるように、redux-observable でできることは「新しく別な action を流せるだけ。元の action に手を加える事は出来ない」っぽい。

qiita.com

diff

最後に、redux-thunk 版と redux-observable 版で diff を取ってみる。

src/App.js
diff --git a/src/App.js b/src/App.js
index d46cfb0..e15919c 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,3 +1,4 @@
+import 'rxjs'
 import React from 'react'
 import { connect } from 'react-redux'
 
@@ -8,17 +9,14 @@ const ActionType = {
   DECREMENT_COUNTER: 'DECREMENT_COUNTER'
 }
 
-const processing = () => ({ type: ActionType.PROCESSING })      // 処理中
-const increment  = () => ({ type: ActionType.INCREMENT_COUNTER })// カウントアップ
-const decrement  = () => ({ type: ActionType.DECREMENT_COUNTER })// カウントダウン
+const incrementAsync = () => ({ type: ActionType.PROCESSING })       // 処理中
+const increment      = () => ({ type: ActionType.INCREMENT_COUNTER })// カウントアップ
+const decrement      = () => ({ type: ActionType.DECREMENT_COUNTER })// カウントダウン
 
-const incrementAsync = () =>  // ★ カウントアップ(非同期)
-  dispatch => {
-    dispatch(processing())    // processing action を dispatch
-    setTimeout(() => {
-      dispatch(increment())   // 500ミリ秒後に increment action を dispatch
-    }, 500)
-  }
+export const epic = action$ =>          // ★ カウントアップ(非同期)
+  action$.ofType(ActionType.PROCESSING) // processing action が dispatch されたとき
+    .delay(500)                         // 500ミリ秒待ってから
+    .mapTo(increment())                 // increment action に変換
 
 
 // Reducer
src/index.js
diff --git a/src/index.js b/src/index.js
index 85640ac..32394bb 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,13 +2,14 @@ import React from 'react'
 import ReactDOM from 'react-dom'
 import { Provider } from 'react-redux'
 import { createStore, applyMiddleware} from 'redux'
-import thunk from 'redux-thunk'
-import App, { counter } from './App'
+import { createEpicMiddleware } from 'redux-observable';
+import App, { epic, counter } from './App'
 
 import './index.css'
 import registerServiceWorker from './registerServiceWorker'
 
-const store = createStore(counter, applyMiddleware(thunk))
+const epicMiddleware = createEpicMiddleware(epic)
+const store = createStore(counter, applyMiddleware(epicMiddleware))
 
 ReactDOM.render(
   <Provider store={store}><App /></Provider>,