reduxとreact-routerの間でURLとstateの同期を行う

reactのルーティング機能?というかテンプレートシステムというか?にreact-routerがあります。リクエストパスに応じて画面構成要素を組み立てるのでreact-routerを使ってアプリケーションを作ると当然URLと画面は同期することができます。URLを直打ちしてもコードでURL遷移させても、適切な画面に切り替わるように作れるのです。

reduxはその際にどうかというと、URLや画面構成といったものについて素で働きかけるものではないので、URLが変化してもreduxのステートマシンにはその情報は反映されません。そこにはもう一つ上乗せするものが必要です。いくつか同じ守備領域のライブラリがあるようですが、わたしはここのところしばらく、中でも主流感のあるreact-router-reduxを用いています。

GitHub - reactjs/react-router-redux: Ruthlessly simple bindings to keep react-router and redux in sync

render、Route、Store

react-router-reduxはURLをreact-routerのURL管理の仕組みHistoryをフックすることでURL変化を取得します。

import React from 'react';
import { render } from 'react-dom';
import { createStore, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import { Router, Route, browserHistory, IndexRedirect } from 'react-router';
import { syncHistoryWithStore } from 'react-router-redux';

import { BookList, BookReader, Login, NotFound, Navigation, Theme } from './containers';
import app from './reducers/AppReducer';
import routing from './reducers/RoutingReducer';

// routingという名前はreact-router-reduxの仕様として固定
const store = createStore(combineReducers({ app, routing }));

render(
    <Provider store={store}>
        <Router history={syncHistoryWithStore(browserHistory, store)} >
            <Route path="/" component={Theme}>
                <IndexRedirect to="book" />
                <Route path="book" component={Navigation}>
                    <IndexRedirect to="list" />
                    <Route path="list" component={BookList} />
                    <Route path=":title" component={BookReader} />
                </Route>
                <Route path="*" component={NotFound} />
            </Route>
        </Router>
    </Provider>,
    window.document.getElementById('application')
);

syncHistoryWithStore(browserHistory, store) でHistoryとreduxのstoreを結びつけていますが、このstoreの中で同期のコードを書くことになります。その前にまず、画面遷移をするためのAction Creatorは以下のようにしました。

// /actions/index.js
import { createActions } from 'redux-actions';

export default createActions(
    {
        MOVE_TO: (title, page = 0) => {
            const finalPage = page < 0 ? 0 : page;
            return { title, page: finalPage };
        },
    },
);

この’MOVE_TO'/moveToは文字列のtitleと数字のpageを設定するものです。サンプルとしてページがマイナスにならないようにだけチェックしてます。redux-actionsの書き方として、昨日の記事のように名前だけでAction Creatorを用意する方法の他、このようにaction.payloadに搭載する内容をコードで調整することも可能です。

アプリケーションのReducer

// /reducers/AppReducer.js
import { handleActions } from 'redux-actions';
import { LOCATION_CHANGE } from 'react-router-redux';

 // DANGER: It's an internal API of react-router!
import { matchPattern } from 'react-router/lib/PatternUtils';

import actions from '../actions';

// URL -> History -> Locationの情報からtitleとpageを抜き出す
function decodeTitleAndPage({ pathname, query }) {
    const m = matchPattern('/book/:title', pathname);
    if (!m) {
        return { title: null, page: 0 };
    }
    const { paramNames, paramValues } = m;
    let title = null;
    for (let i = 0; i < paramNames.length; i += 1) {
        if (paramNames[i] === 'title') {
            title = decodeURI(paramValues[i]);
            break;
        }
    }
    const page = (query.page && Number(query.page)) || 0;
    // titleとpageを返している!
    return { title, page };
}

export default handleActions({
    // こちらはアプリケーションの基本的なReducer。なにはなくともこれは書くことでしょう
    [actions.moveTo]: (state, { payload: { title, page } }) => ({
        ...state,
        title,
        page,
    }),
    // こちらがreact-router-reduxの作法として実装すべき内容。LOCATION_CHANGEをappとして監視する。
    [LOCATION_CHANGE]: (state, { payload }) => ({
        ...state,
        ...decodeTitleAndPage(payload),
    }),
}, { /* default state here */ });

まずアプリケーション側のReducerですが、こちらはreact-router-reduxの定義する"LOCATION_CHANGE" Actionを処理するものを作ります。このActionはpayloadにURL由来のLocationを積んでるので、そこからリクエストされているパスやクエリの内容を取り出します。取り出しているのは、上記のdecodeTitleAndPage()と書いてあるところです。

こちらでは手抜きでreact-routerの内部APIを使ってます。パスを分解して当たり判定するコードを書くのが大変なのでreact-routerで用いられているmatchPattern関数を引っ張ってきました。この関数を用いてreact-routerの仕様で書かれたルートパラメータであるtitleを取り出すことができました。取り出した値はアプリケーションのstateに保持されます。このことによってURLからRedux Storeへの方向の同期ができるようになりました。

ルーティングのためのReducer

// reducers/RoutingReducer.js
import { handleActions } from 'redux-actions';
import { LOCATION_CHANGE } from 'react-router-redux';
import actions from '../actions';

// titleとpageからLocationオブジェクトを作る
function encodeTitleAndPage({ locationBeforeTransitions }, { title, page }) {
    const pathname = `/book/${encodeURI(title)}`;
    return {
        ...locationBeforeTransitions,
        pathname,
        query: { page: page.toString() },
        search: `?page=${page}`,
        action: 'PUSH',
    };
}

export default handleActions({
    // こちらがreact-router-reduxの作法として実装すべき内容。moveToをroutingとして監視する。
    [actions.moveTo]: (state, { payload }) => ({
        ...state,
        locationBeforeTransitions: encodeTitleAndPage(state, payload),
    }),
    // こちらはreact-router-reduxのお約束。
    [LOCATION_CHANGE]: (state, { payload }) => ({
        ...state,
        locationBeforeTransitions: payload,
    }),
}, { locationBeforeTransitions: null });

もうひとつ、逆にRedux StoreからURLへの方向の同期を行うためのものです。このReducerは”routing”の名前でStoreに登録されるべきもので、冒頭にでてきてStoreとHistoryを結びつけたsyncHistoryWithStoreから参照されるものになります。stateのrouting.locationBeforeTransitionsが変化すると、HistoryのAPIを呼び出してURL遷移します。URLの変化によってsyncHistoryWithStoreが投げるLOCATION_CHANGEに対応するものに加えて、アプリケーションコードからtitleとpageを指定することで期待する画面遷移を、moveToのReducerで書きます。これは例でencodeTitleAndPageとした関数で書いていて、moveToのpayloadに積まれているtitleとpageをLocationオブジェクトに反映しています。

  • Location
    • pathname: 遷移先パス
    • query: クエリパラメータ
    • search: 遷移先URLに追加される、クエリパラメータを含んだ文字列。
    • action: PUSHいれておけばOK。ちょっと他のケースが思いつかない。

冗長に思うけどqueryと両方設定してあげないとダメな作りだった。注意されるべし。

combineReducersで分割管理されたReducer群の挙動

この一連の作りを掘った際に深く理解できたのは、ReduxのcombineReducersでまとめたReducerの動きであり、一度Actionが投げつけられれば、分割されたすべてのReducerにActionがバインドされるってことです。アプリケーションとルーティングのための二つのReducerとしてappとroutingが同じstateの中に名前空間をつくってますが、appにもroutingにもmoveToが同時に発火し、LOCATION_CHANGEも同時に発火する。だからこの例のような作りができる。

感想

これで、URLを直で入力されてもステートマシンに適切に反映され、アプリケーションでreduxのactionをバインドするだけでURL遷移させることができるようになりました。これは結果としてブラウザの戻るボタンや進むボタンの対応も完全におこなわれたということであり、それがreact-router-reduxのサイトの説明にある謎の言葉「time travel」の説明となります。

You want to do time travel with your application state, but React Router doesn't navigate between pages when you replay actions. It controls an important part of application state: the URL.

(https://github.com/reactjs/react-router-redux より引用)

初見では何言ってんのかわかりませんでしたが、なるほど納得。この文はステートマシン->URLのことしか言ってないように読めますけど、説明したように逆も大事。react-router-reduxの標準のサンプルにはURL -> ステートマシンのことが書かれてなかったので見はじめてしばらくはダメダメなライブラリと思ってました。その後に全くの別件でcombineReducersのつかいこなし例を見たときにはじめてreact-router-reduxの思想に思い至り、ビビビと痺れた。routing Reducerは標準で用意されているものではなく、自分で書かないと真価が発揮されないものなのだと。つまりおしいかな、ライブラリで用意されたドキュメントとサンプルは説明が足りない。

備忘録

routing.locationBeforeTransitions がお約束の言葉。