あけましておめでとうございます。
正月明けでやや時間が空きましたが、続きです。
React関連の使い方を復習する/(1) create-react-app
React関連の使い方を復習する/(2) prop
React関連の使い方を復習する/(3) stateとイベントハンドラ
React関連の使い方を復習する/(4) prop-typesとreact-dnd
前回、react-dndを導入してイベントが増えてきましたが、徐々にコンポーネント間の相関が強くなってきて流れを読み解くのが辛くなってきました。stateの操作の流れが一定でないため、このまま規模を増やしていくのは大変そうでしたね。
今回は、react-reduxを導入します。reduxは、Fluxのアイデアを進化させたものとのこと。ということで、まずFluxについて簡単に触れておきます。
Flux
FluxのOverviewを日本語にすると、
Fluxは、アプリケーションのデータフローを管理するためのパターンです。最も重要な概念は、データが一方向に流れることです。
ということです。書いてある通り、「データが一方向に流れる」というのがポイントです。前回作ったアプリケーションは、コンポーネントのPropsを通じて子/親コンポーネント間で相互にデータのやり取りがありました。小規模であればこれで問題ないでしょうが、規模が増えてくるとやはり辛い。そこで、データフローを一方向にすることができれば全体の見通しが良くなる、ということです。
図に登場するそれぞれの役割を簡単にまとめると、以下になります。
- Action:入力内容からデータを作成
- Dispatcher:データをStoreに送る
- Store:データを蓄積する
- View:データを表示する
Redux
では、Reduxとは何者でしょうか。
参考:UNIDIRECTIONAL USER INTERFACE ARCHITECTURES
公式から図解を見つけられなかったので、ホワイトボードに絵を描いて貼り付けようかと思ったんですけど、世に出回っている絵の方が齟齬がないと思うので、一番自分の理解とあっているものを。
登場人物の紹介。
- ViewProvider:ReduxはUIに関するViewを提供するものではないため、ReactやAngularなどをViewとして用いることができる。今回はReactのコンポーネントをProviderでラップするようなイメージ。
- Actions:絵には出てきませんが、ActionCreatorで定義した関数をユーザの操作を契機に呼び出して、ユーザの入力情報からStoreで保持するstateを更新するためのActionオブジェクトを生成してStoreに受け渡す
- Middleware:Reducerの実行前後に中間処理を追加する仕組み
- Reducer:ViewProviderから渡されるActionオブジェクトとStoreが保持する古いStateから、新しいStateを生成してStoreを更新する
- Store:アプリケーション全体のStateを保持し、これを元にViewを描画する。StateはReducerからのみ更新される。シングルトン。
- State:アプリケーション全体を描画するためのState。JSONの塊みたいなもの。Reactと組み合わせて使う場合は、コンポーネントごとに持っていたStateの管理を全て移譲する。
Vue.jsを使う場合は、登場人物の多少の違いはあれ、同じ思想のVuexがありますね。一昔前にはMVVMとかMV*とか色々とありましたが、最近はFluxから派生した思想が多いようで、Reactに限らずフロントエンドに関連する知識として持っておいて損はないのだと思います。
Reduxを導入する
概念の説明はここまでにして、ソースコードを見ていきましょう。
index.js
これまでは、DOMに対してReactのコンポーネントを適応する設定と、コンポーネントにPropsとして受け渡す固定値の定義だけをしていました。今回は、Reduxを利用するためにProviderやStoreの設定を行います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// before import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; import registerServiceWorker from './registerServiceWorker'; const card1Tasks = [ (snip) ] ReactDOM.render( <App cards={cards}/>, document.getElementById('root') ); registerServiceWorker(); // after import React from 'react'; import ReactDOM from 'react-dom'; import registerServiceWorker from './registerServiceWorker'; import { createStore } from 'redux'; import { Provider } from 'react-redux'; import App from './App'; import todoReducer from './reducers/TodoReducer'; ReactDOM.render( <Provider store={createStore(todoReducer)}> <App/> </Provider>, document.getElementById('root') ); registerServiceWorker(); |
プラスαを除いてシンプルな形にするとこんな感じです。
L23-L24でimportしたものをL30で利用しています。Storeを作るときの引数にReducerをとります。TodoReducerはこれから作る予定のものです。
複数のReducerをStoreに持たせる場合は、CombineReducersを利用します。また、Middlewareを設定する場合はapplyMiddlewareを利用します。試しに、Laneの状態を管理するLaneReducerを追加して複数にし、さらにStoreのStateが更新されるたびにログを出力するredux-loggerをMiddlewareに追加して見ましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import React from 'react'; import ReactDOM from 'react-dom'; import registerServiceWorker from './registerServiceWorker'; import { createStore, applyMiddleware, combineReducers, } from 'redux'; import { Provider } from 'react-redux'; import { createLogger } from 'redux-logger' import { Iterable } from 'immutable'; import App from './App'; import TodoReducer from './reducers/TodoReducer'; import LaneReducer from './reducers/LaneReducer'; ReactDOM.render( <Provider store={createStore( // 複数のReducerを利用する場合 combineReducers({ TodoReducer, LaneReducer }), applyMiddleware(createLogger()) )}> <App/> </Provider>, document.getElementById('root') ); |
設定が多くて初学だと敬遠したくなりますが、順に見ていくとそれほど複雑ではないですね。
Appコンポーネント
続いて、Appコンポーネントを見ていきます。
Reduxのconnect関数を使ってStoreと接続することにより、
- Storeが管理するState
- Actionオブジェクトを生成するActionCreatorの関数
をコンポーネントのPropsにマッピングする設定を追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
import React from 'react'; import './App.css'; import Lane from './components/Lane' import HTML5Backend from 'react-dnd-html5-backend'; import { DragDropContext } from 'react-dnd'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import TodoAction from './actions/TodoAction'; class App extends React.Component { // コンポーネントが最初に描画される前に1度だけ呼び出される関数 componentWillMount() { // カードの初期状態を const card1Tasks = [ (snip) ]; this.props.addCards(cards); } render() { const { lane, cards, ...actions } = this.props; const { TODO, WIP, DONE } = lane; // レーンごとのカードを抽出する const todoCards = cards.filter(card => card.status === TODO.ID); const wipCards = cards.filter(card => card.status === WIP.ID); const doneCards = cards.filter(card => card.status === DONE.ID); return ( <div className="app"> <Lane id={TODO.ID} label={TODO.LABEL} cards={todoCards} actions={actions} /> <Lane id={WIP.ID} label={WIP.LABEL} cards={wipCards} actions={actions} /> <Lane id={DONE.ID} label={DONE.LABEL} cards={doneCards} actions={actions} /> </div> ); } } App = DragDropContext(HTML5Backend)(App); export default connect( state => { return { cards: state.TodoReducer, lane: state.LaneReducer }; }, dispatch => { return bindActionCreators(TodoAction, dispatch); } )(App) |
L63-L74、ActionCreatorとStateをコンポーネントとマッピングする設定を追加しています。既にDragDropContextが設定されていたため、L63のようにAppの設定を一度保持した上でconnectしたAppコンポーネントをexportします。
L65-L70で、Storeがもつstateをコンポーネントに紐づけています。Reducerごとに引数のstateに保持されているので、それらをpropsにどのようなプロパティで格納するかを指定します。
L71-73で、ActionCreatorで定義した関数をコンポーネントに紐づけています。今回は紐づけるActionは一つなので第一引数にそのまま渡すだけですが、複数のActionを紐づける場合はObject.assignを利用して複数のActionを1つのオブジェクトにして渡してあげれば良いです。
L13-L19、これまでindex.jsで固定値でカードの初期状態を定義していましたが、stateの管理を全てStoreに移譲するため、今後初期状態をサーバから取得するということを見越してAppコンポーネントの初回描画時にActionCreatorを呼び出してStoreを更新するように修正します。L18で呼び出しているaddCardsは、これから作成するActionCreatorの関数です。L72でマッピングされているという状態です。
L22-L26、コンポーネントのPropsに紐づけたstateやactionを取得しています。
特に決まりがあるわけではありませんが、stateに対してactionは多くの関数を持っていることが多いため、Reducerごとのstateは個々の変数に格納し、actionはspread operatorを用いて1つのオブジェクトにまとめてしまい、そこから関数を呼び出すようにしています。actionが持つ関数を1つずつ展開していると、コンポーネント間のPropsバケツリレーが大変なので、個人的にはこのやり方がスマートだと思っています。
数が多いため書きませんが、Laneコンポーネント以下、コンポーネントごとにstateを保持していた記述は全て削除し、stateを変化するときに呼び出していた処理ではPropsに渡されているactionsから該当の関数を呼び出すように修正します。
TodoAction、TodoReducer
続いて、ActionCreatorとReducerを作成します。
素のReduxを用いてActionを作ると少々冗長な記述になるため、公式でも推奨しているredux-actionを用いてActionを作成します。簡略化のため、関数を一つだけ定義したActionとしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
--- TodoAction --- // redux-actionsを使わない場合 // ActionTypeを別途定義して、ActionCreatorとReducer双方で利用する必要がある export const TODO = () => { return { ADD_CARDS: 'ADD_CARDS' }; }; export function addCards(cards) { return { type: STRATEGY().ADD_CARDS, payload: cards }; } --- TodoReducer --- // TodoActionsで定義したActionTypeを読み込む import { TODO } from '../actions/TodoAction'; const initialState = { cards: [] }; export default function todoReducer(state = initialState, action) { // ActionTypeごとに処理を分岐してstateを更新 switch (action.type) { case TODO().ADD_CARDS: { // 引数で渡されるactionやstateを更新するのではなく、それらから新しいインスタンスを作る必要がある // 配列であればconcat、オブジェクトであればObject.assignを用いる const cards = [].concat(action.payload.cards); return Object.assign({}, state, {cards}); } default: { return state } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
--- TodoAction --- // redux-actionsを使う場合 import { createActions } from 'redux-actions' export default createActions({ // スネークケースでActionTypeを定義する 'ADD_CARDS': cards => { // 戻り値が自動的にactionオブジェクトのpayloadに格納されてReducerに渡される return { cards } } }); --- TodoReducer --- import TodoAction from '../actions/TodoAction'; import { handleActions } from 'redux-actions'; const initialState = { cards: [] }; export default handleActions({ // スネークケースで定義したActionTypeをキャメルケースで読み込める [TodoAction.addCards]: (state, action) => { const cards = [].concat(action.payload.cards); return Object.assign({}, state, {cards}); } }, initialState); |
redux-actionsを使っている方が、ボイラープレートが排除されてすっきりとしていますね。特に、redux-actionsを使わない場合はActionTypeを定義してexport/importして・・・というところと、ReducerでActionTypeごとにswitchで分岐して・・・というところが辛いですね。
今回は説明のためにActionやReducerに関数を1つしか定義していませんが、stateの更新する処理ごとにこれを作り込んでいくことになります。
まとめ
以上でreduxとredux-actionsの導入は終了です。動いているものはこれまでと変わりませんが、データの流れが一方向になったことにより、コード全体の見通しや拡張性が改善されました。
カンバンを作り上げることが本質ではないのでコードは全部載せていませんが、ActionやReducer、Componentをredux導入に合わせて修正を加えたソースコードをGithubに置いておきます。
次回はStoreで管理するStateにimmutable.jsを導入していきます。