いよいよ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と呼んでる。このアプリでは登場場所がなさそうですが、次の機会に使って見たい。これも実装としてはごく小規模なもので見通しよいです。

github.com