いよいよreact-router-redux v5へ
一昨日、昨日の続きです。
react-routerをv4へマイグレーションすると同時に、react-router-reduxもv5に上げます。こちらはまだアルファ版ということですが、十分に機能するしとてもシンプルなためにコード読んですぐ分かる内容ですから、危険視するほどではないと思います。
$ npm install --save react-router-redux@next $ npm install --save history
5.0.0-alpha.6でした。ここ1ヶ月ぐらいはコードが動いていないし、まあほぼ完成なのでしょう。
// Root.jsx import React from 'react'; import injectTapEventPlugin from 'react-tap-event-plugin'; import { createBrowserHistory } from 'history'; import { createStore, combineReducers, applyMiddleware } from 'redux'; import { Provider } from 'react-redux'; import { ConnectedRouter, routerMiddleware } from 'react-router-redux'; import { Switch, Route, Redirect } from 'react-router-dom'; import { reducer as form } from 'redux-form'; import app from './reducers/AppReducer'; import { BookList, BookReader, Navigation } from './containers'; import moveToMiddleware from './middlewares/MoveToMiddleware'; injectTapEventPlugin(); const history = createBrowserHistory(); const store = createStore( /* routerReducerはLOCATION_CHANGEアクションに反応して引数のhistory.locationを保存するだけなので通常は不要 */ combineReducers({ app, form }), applyMiddleware(routerMiddleware(history), moveToMiddleware), ); export default () => ( <Provider store={store}> <ConnectedRouter history={history}> <Navigation> <Switch> <Redirect exact from="/" to="/book" /> <Route exact path="/book"><BookList /></Route> <Route path="/book/:title/:vol/:page"><BookReader /></Route> <Route><h1>Not Found</h1></Route> </Switch> </Navigation> </ConnectedRouter> </Provider> );
Providerコンポーネントはreact-reduxです。その直下にあるConnectedRouterコンポーネントがreact-router-reduxのもので、これはhistory.listenしていて、historyに変化が生じたら「LOCATION_CHANGE」アクションをdispatchするのが役目です。history関連を処理した後はreact-routerの標準Routerコンポーネントを書き出してhistoryを保持させています。storeへはreact-router-reduxのミドルウェア「routerMiddleware」を登録します。routerMiddlewareは「CALL_HISTORY_METHOD」アクションに反応して引数のhistoryを操作します。この操作で実際にブラウザのURLが動きます。
ここまでライブラリお仕着せの仕組みですが、これからアプリケーション側でどのようにURLに対応させたいかの実装を行います。
- 直接URLをブラウザに入力されたり、ブラウザの戻る・進むボタンが押された時に変化したURLから情報をとる
- アプリケーションで抽象化された操作でのStoreの状態変化に従い、URLを遷移させる。
この二つの対応を、1) はアプリケーションのReducerでLOCATION_CHANGEを補足して実現し、2) はミドルウェアで実現することにしました。
// AppReducer.js import { handleActions } from 'redux-actions'; import { LOCATION_CHANGE } from 'react-router-redux'; import { matchPath } from 'react-router-dom'; import { Map } from 'immutable'; import actions from '../actions'; const decodeTitleAndPage = (pathname) => { const m = matchPath(pathname, '/book/:title/:vol/:page'); if (m) { const strPage = m.params.page; let numPage = strPage ? Number(strPage) : 0; numPage = (numPage < 0) ? 0 : numPage; // m.paramsにはmatchPathに渡したExpress記法で指定された { title, vol, page }が入ってる return { ...m.params, page: numPage }; } return {}; }; export default handleActions({ [actions.handleDrawer]: (state, { payload }) => state.merge({ open: payload }), [actions.togglePageLayout]: state => state.update('pageLayout', pageLayout => !pageLayout), [actions.requestBooks]: state => state.merge({ books: null, message: null, fetch: true }), [actions.receiveBooks]: { next: (state, { payload }) => state.merge({ books: payload, message: null, fetch: false }), throw: (state, { payload: { message } }) => state.merge({ books: null, message, fetch: false }), }, [actions.moveTo]: (state, { payload: { title, vol, page } }) => { if (state.get('title') !== title || state.get('vol') !== vol) { return state.merge({ title, vol, page, pageLayout: false }); } return state.merge({ page }); }, [LOCATION_CHANGE]: (state, { payload: { pathname } }) => state.merge(decodeTitleAndPage(pathname)), }, Map({ /* default state here */ }));
Reducerはreact-actionsを利用しています。Flux Standard Action仕様にのっとっているActionとReducerを簡単に作る仕組みで、愛用しています。今回はさらにImmutable.jsを用いて書き直しました。遷移に関係するのは、[actions.moveTo]と[LOCATION_CHANGE]ですが、ReducerのほうではmoveToは特に変わったことをしません。LOCATION_CHANGEは、react-router v4で用意されたmatchPathを用いてExpress記法のURLからアプリケーションとして意味のある変数を取り出し、Storeに反映させます。
// MoveToMiddleware.js import { push } from 'react-router-redux'; export default store => next => (action) => { next(action); // routerMiddlewareがイベントを喰っちゃうのでdispatch(push())の前にnextを呼ぶ if (action.type === 'MOVE_TO') { const { payload: { title, vol, page } } = action; store.dispatch(push(`/book/${title}/${vol}/${page}`)); } };
moveToMiddlewareは自前のMOVE_TOアクションに反応して、CALL_HISTORY_METHODアクションをdispatchします。上記コードではreact-router-reduxが用意した「push」アクションクリエーターがCALL_HISTORY_METHODアクションを作ってくれます。これが次のラウンドでrouterMiddlewareに引っかかり、history操作が行われてブラウザURLが遷移することとなります。
さらに、immutable.jsでStoreを作り変えたために、react-reduxの箇所で対応が必要。
// Navigation.js import axios from 'axios'; import { connect } from 'react-redux'; import NavigationComponent from '../components/NavigationComponent'; import actions from '../actions'; const { handleDrawer, togglePageLayout, requestBooks, receiveBooks, moveTo } = actions; function mapDispatchToProps(dispatch) { return { handleDrawer: open => dispatch(handleDrawer(open)), togglePageLayout: () => dispatch(togglePageLayout()), fetchBooks: () => { dispatch(requestBooks()); axios({ url: '/data/list', timeout: 30000, method: 'get', responseType: 'json' }) .then(response => dispatch(receiveBooks(response.data))) .catch(response => dispatch(receiveBooks(new Error(response.data)))); }, moveTo: (title, vol, page) => dispatch(moveTo(title, vol, page)), }; } export default connect(state => state.app.toJS(), mapDispatchToProps)(NavigationComponent);
このコードは、画面コンポーネントに対してのコンテナ(container)です。react-reduxでconnectするところで、Storeがimmutable.MapなのでtoJS()を呼び出して普通のJSオブジェクトに戻しています。これで、ReduxのStoreが完全にブラウザの動きに連動したアプリケーションを作ることができました。
ちなみに、Reduxでの非同期処理について、いろんなやり方がありますが私はこの画面コンポーネントの近いところ、コンテナの層でPromiseもしくはPromiseベースなライブラリを使って実装するのが、redux-actionsとの相性も良くて作りやすいと思ってます。
さらには、このstateをコンポーネントのプロパティにマップするところ、reselectという考え方がドキュメントより目に入りました。小さなGetterを数珠繋ぎにしてロジックをつくることでStoreに計算項目を持たせないというものです。Storeから値取得するところをGetterと呼ぶのと区別して?デザインパターンとしてSelectorと呼んでる。このアプリでは登場場所がなさそうですが、次の機会に使って見たい。これも実装としてはごく小規模なもので見通しよいです。