Reduxを動かしながら理解する

巷で話題のReduxを仕事で使えそうです。ただReact/fluxを半年前に趣味のサービス開発でけっこう触っていたのに、Reduxはドキュメントを読んだだけではピンと来ず…。動かさないと分からないので、スターターキットを利用しながら実際に触ってみたいと思います。

react-redux-starter-kit

https://github.com/davezuko/react-redux-starter-kit

スターターキットのくせにスターが2000以上ついてるスゴイやつです。

ちなみにReduxは関連するライブラリや開発ツール等のエコシステムもすごいのですが、スターターキット(Boilerplate)も恐ろしいほど数があります。関連するライブラリやツール等の情報はawesome-reduxにまとまっています。Reduxの最初のコミットが5月末でホットリローディングと共にお披露目されたのが2015年7月のReact Europeという事を考えると本当にスゴイ勢いですね…。

前提条件

  • node ^4.2.0
  • npm ^3.0.0

ぼくの環境だとNodeはv0.10で古かったのでインストールし直しました。

さっそくスターターキットを動かす

まずgit cloneして、npm installします。

$ git clone https://github.com/davezuko/react-redux-starter-kit
$ cd react-redux-starter-kit
$ npm install

無事、npm installが完了したらnpm startを実行します。「webpack: bundle is now VALID.」というメッセージが表示されたらhttp://localhost:3000をブラウザで開きます。

NewImage

右側にあるのが開発用ツールでStore(簡単に言うと保持されるアプリケーションの状態)の変更履歴が参照できたりそれをリセットしたり任意の時点のものに戻したり(Commitの後にRevert)することが出来るようです。また、ホットリローディングに対応しておりソースコードを変更して保存すると自動的に再読み込みしてくれます(全てのソースコードが対応しているわけではない模様)。F5で再読込する必要はないですしStoreの状態はそのままなので何回もブラウザ上で同じ操作をしなくてもデバッグができます。

WebPackが生成するJSについて

WebPackのすごく良い所なんですが、ビルドされて出来るJSが2つに分かれています。

  • app.xxxx.js
  • vendor.xxxx.js

app.jsは自分で開発したコードがまとめられたもの、vendor.jsはライブラリ類がまとめられています。このように2つに分かれていれば、SPAを複数作った時(たとえば画面遷移はさせるんだけど其々SPAにしておく等の場合)にvendor部分のロードをしなくてもキャッシュから利用できるため軽いサイトに出来そうです。

スターターキットのソースコード

このスターターキットのソースコードを理解するにはReduxそのものも当然そうなのですが、まずその他にどんなライブラリが利用されているか知っておく必要があります。

react-routerはそのままで以前使っていたので分かります。redux-simple-routerはreact-routerをreduxで使うためのライブラリのようなので特に問題ないです。redux-actionsとredux-thunkが目新しかったです。

redux-actions

ActionCreatorを作成したり(createAction)、ReducerをStoreに紐付けたり(handleActions)してくれるという理解です。

redux-thunk

どうもActionCreatorは同期的に実行されるのが良いようなのですが、じゃあAPIをコールしたりする非同期の処理を実行したい場合はどうするのかという場合にこのredux-thunkを使うようです。他にも方法はあるようですが。

ソースコードの全体像

スターターキットのファイル構成は下記のようになっています。これから引用するソースコードは要点のみ抜粋しています。

$ tree src -L 3
src
├── components
├── containers
│   ├── DevTools.js
│   ├── DevToolsWindow.js
│   └── Root.js
├── index.html
├── layouts
│   └── CoreLayout
│       └── CoreLayout.js
├── main.js
├── redux
│   ├── configureStore.js
│   ├── modules
│   │   └── counter.js
│   ├── rootReducer.js
│   └── utils
│       └── createDevToolsWindow.js
├── routes
│   └── index.js
├── static
│   ├── favicon.ico
│   ├── humans.txt
│   └── robots.txt
├── styles
│   ├── _base.scss
│   ├── core.scss
│   └── vendor
│       └── _normalize.scss
└── views
    ├── HomeView
    │   ├── Duck.jpg
    │   ├── HomeView.js
    │   └── HomeView.scss
    └── NotFoundView
        └── NotFoundView.js

表示されるところまで

main.js

このmain.jsが最初のエントリポイントです。

ReactDOM.render(
  <Root history={history} routes={routes} store={store} />,
  document.getElementById('root')
)

getElementByIdで指定してるのはindex.htmlの下記です。

<body>
  <div id="root" style="height: 100%"></div>
</body>

containers/Root.js

main.jsでhistory={history} routes={routes} store={store}と渡していたものがpropTypesでチェックされRootコンポーネントの中で利用されているのが分かります。

  static propTypes = {
    history: PropTypes.object.isRequired,
    routes: PropTypes.element.isRequired,
    store: PropTypes.object.isRequired
  };
  ...
  get content () {
    return (
      <Router history={this.props.history}>
        {this.props.routes}
      </Router>
    )
  }
  ...
  render () {
    return (
      <Provider store={this.props.store}>
        <div style={{ height: '100%' }}>
          {this.content}
          {this.devTools}
        </div>
      </Provider>
    )
  }

Routerコンポーネントの中がthis.props.routesになってますが、これはroutes/index.jsで定義された下記です。

export default (
  <Route path='/' component={CoreLayout}>
    <IndexRoute component={HomeView} />
    <Route path='/404' component={NotFoundView} />
    <Redirect from='*' to='/404' />
  </Route>
)

これでURLに該当するReactコンポーネントを紐付けていてRouterに任せていれば自動的に切り替わってくれます。たとえばリンクなどする場合に<Link to='/404'>Go to 404 Page</Link>という形で遷移させます。このあたりは通常のReactですね。

http://localhost:3000にアクセスした時に表示されるのはIndexRouteなので、views/HomeView/HomeView.jsのReactコンポーネントが表示(render)されます。

Reduxとの連携

次に、Reduxの部分を見ていきます。

views/HomeView/HomeView.js

下記のようにReactコンポーネントのHomeViewとReduxの仕組みが関連付けられています。

...
import { connect } from 'react-redux'
import { actions as counterActions } from '../../redux/modules/counter'

const mapStateToProps = (state) => ({
  counter: state.counter
})
export class HomeView extends React.Component {
  static propTypes = {
    counter: PropTypes.number.isRequired,
    doubleAsync: PropTypes.func.isRequired,
    increment: PropTypes.func.isRequired
  };
  render () {
    ...
  }
}
export default connect(mapStateToProps, counterActions)(HomeView)

mapStateToPropsはその名のとおりReduxのStateをReactのpropsに紐付けるものです。この関数が最後のconnectに渡される事でstateの情報をReactコンポーネント内のprops(今回はthis.props.counter)として利用できるようになります。counterActionsはActionCreatorの集合です。このActionCreatorもstateと同様propsとして利用できるようになります。下記はそのActionCreatorを呼ぶところ。

<button className='btn btn-default' onClick={() => this.props.increment(1)}>

ちなみにReduxでよく見る@connectはDecoratorというES7の形式だったりするのですが、それとは違ってこのように書いているとテストしやすい等のメリットがあるようです。

https://github.com/davezuko/react-redux-starter-kit/issues/74

ActionCreatorとAction

ちなみに、先ほどonClickに() => this.props.increment(1)を渡していたのですが、onClick等のイベント発生時に任意の値をひも付けて関数を呼びたい時にこのように無名関数を使う事がよくあります。そして、このincrementというActionCreatorはredux/modules/counter.jsに定義されています。

export const increment = createAction(COUNTER_INCREMENT, (value = 1) => value)
...
export const actions = {
  increment,
  doubleAsync
}

ただ、ここで作られたincrementとthis.props.incrementはやや中身が違って、this.props.incrementはdispatchを呼ぶように変わっています。Todo Listのサンプルを見るとReactコンポーネントの中でdispatchを呼んでたりするのにこのスターターキットの中にないのはこの仕組みがあったからでした。ActionCreatorは実際はここでは下記のように呼ばれています。connectすると変化するということですね。

function () {
  return dispatch(actionCreator.apply(undefined, arguments));
}

ちなみに通常のRedux/fluxではActionCreatorが作るActionはただのオブジェクトで開発者が自分でオブジェクトの構造をデザインしますが、最近ではFlux Standard Action(FSA)というものがあります。このスターターキットで利用しているredux-actionsはこのFSAに則ったActionを生成します。

Flux Standard Action

たとえばTodo Listの例だとActionはこんな感じですが、

{
  type: ADD_TODO,
  text: 'Build my first Redux app'
}

FSAだとActionのコンテンツはpayloadというキーになります。

{
  type: ADD_TODO,
  payload: 'Build my first Redux app'
}

その他にはerrorやmetaというキーを利用するようです。

役割がイマイチ覚えにくいReducer

Reducerってどういう意味なんでしょう?ぼくの知識不足だと思いますが、やってる事と名称が頭の中であまり結びついてないです。ぼくの浅い理解だとReducerはfluxのStoreから部分的にロジックを切り出したもので、ActionからどのようにStoreの値へ落としこむかのロジックだと思います(値を書き換えないという違いはありますが)。fluxだとこれを完全にStoreの内部でやっていて、AppDispatcher.registerのcallbackでswitch文まみれになっている箇所がそれです。このサンプルなんかはReducerですがswitch文になっていてStoreのあの箇所とよく似ています。

function todoApp(state = initialState, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return Object.assign({}, state, {
        visibilityFilter: action.filter
      })
    case ADD_TODO:
      return Object.assign({}, state, {
        todos: [
          ...state.todos,
          {
            text: action.text,
            completed: false
          }
        ]
      })    
    default:
      return state
  }
}

これがスターターキットだとreact-actionsを利用しているので少し違っています。

export default handleActions({
  [COUNTER_INCREMENT]: (state, { payload }) => state + payload
}, 1)

switch文ではなくて、handleActionsに渡されるオブジェクトのキーでActionと結び付けられています。(state, { payload }) => state + payloadの部分がReducerです。ちなみにこのhandleActionsの第2引数の1はStoreの初期値です。このサンプルではStateはひとつの数字だけという形になっています。

ReducerがfluxのStoreと大きく違うのは値を書き換えないところです。Reducerは最後に次にStoreとなるべき値をreturnするだけです。第1引数のstateが状態で第2引数がActionです。今回はstateはプリミティブ型の数値なので書き換えようがないんですが、たとえばオブジェクトだったとしてstate.counter = 1みたいにするとアウトです。

React/Redux苦労話 2015

上記のエントリでは、Immutable.jsを利用して回避したそうです。こんな感じになります。

import Immutable from 'immutable'
...
export default handleActions({
  COUNTER_INCREMENT: (state, { type, payload }) => {
    return state.set('counter', state.get('counter') + payload)
  }
}, Immutable.Map({
  counter: 1,
  isFetching: false
}))

state.getなどメソッド経由でアクセスするのが面倒ですが、Reactコンポーネント等の読み取りで利用する場合はtoJS()でオブジェクト化してアクセスする事もできます。

return Object.assign({}, state, {counter: state.counter + payload})

Object.assignを使うほうが一般的かもしれません。

非同期のActionCreatorはreact-thunkを使う

下記がreact-thunkを利用しているActionCreatorの関数です。中で関数をreturnしておりそれをreact-thunkが遅延評価する形になっています。

redux/modules/counter.js

export const doubleAsync = () => {
  return (dispatch, getState) => {
    setTimeout(() => {
      dispatch(increment(getState().counter))
    }, 1000)
  }
}

ここでやっている事は簡単で、1秒待ってincrementのActionCreatorを呼び出してCOUNTER_INCREMENTのActionをdispatchするだけです。

views/HomeView/HomeView.js

<button className='btn btn-default'
  onClick={this.props.doubleAsync}>
  Double (Async)
</button>

なお、このthis.props.doubleAsyncも他のincrement等と同様にdispatchを使う形に変わっています。ちなみにreact-thunk自体はMiddlewareになっていて、MiddlewareはActionとReducerの間で処理が走るようです。

function () {
  return dispatch(actionCreator.apply(undefined, arguments));
}

今回のサンプルだと1秒待つだけなので逆にシンプル過ぎて少し分かりづらかったのですが、もう少し実際のユースケースを考えると下記のようになると思います。

export const heavyCalc = () => {
  return (dispatch, getState) => {
    const counter = getState().counterState.counter;
    dispatch(requestHeavyCalc())

    setTimeout(() => {
      const result = counter * 99
      dispatch(responseHeavyCalc(result))
    }, 3000)
  }
}

export default handleActions({
  COUNTER_REQ_CALC: (state, { type, payload }) => {
    return state.set('isCaluculating', true)
  },
  COUNTER_RES_CALC: (state, { type, payload }) => {
    return state.set('isCaluculating', false).set('counter', payload)
  }
}, Immutable.Map({
  counter: 1,
  isCaluculating: false
}))

非同期処理の前後でそれぞれActionをdispatchしReducerではリクエスト時にisCaluculatingをtrueにレスポンス時にisCaluculatingをfalseにしています。このような形でAction->Reducer->Viewsと流していけばロード中の表示等も簡単にできます。

Reduxを触ってみて

Reactコンポーネントから呼ぶActionCreatorの実装が変わってて内部的にdispatchを呼んでいるのが逆に分かりにくくて混乱しました…。Todo Listのサンプルではdispatchを明示的に呼んでるみたいなのでReduxというより作り方の問題なのかもしれませんが。少なくともconnect経由でAction を渡すとそうなるという事ですね。

全体的にはStoreの存在感がソースコード上ほぼ消えていますね。なのに逆にStateの管理性は良くなっているようです。また、ReactコンポーネントからstateもsetStateも消えてReduxのStateがReactコンポーネントのpropsに直結するようになった。fluxのバケツリレーのようなコーディングがなくなってコード量は必要最低限になっている印象です。良いかも。