reactjs
React
react-router

react-routerでquery stringを扱う

目的

React製のアプリケーションの状態の一部をURLのquery stringで保持したい場合があります。状態の一部をquery stringで持つことの利点には以下のようなものが挙げられます。

  • URLをブックマークに保存することでアプリケーションの状態を保存することができる
  • URLを共有することでアプリケーションの状態を他の人と共有することができる

しかしながら,アプリケーションの状態とquery stringを同期させるには一手間かかります。具体的には以下のようなケースに対応する必要があります。

  1. アプリケーションの状態が変更されたらquery stringも変更する
  2. query stringが指定されたURLにアクセスされたらアプリケーションの初期状態をquery stringと一致させる
  3. ブラウザの戻るボタンや進むボタンが押されてquery stringが変更された場合にもアプリケーションの状態をquery stringと一致させる

この中では特に3番目の戻るボタンと進むボタンの対応が曲者です。ページが読み込まれたときにのみquery stringと一致させればよいと思うかもしれませんが,シングルページアプリケーションでは通常はHTML5のpushState/replaceStateを使ってページの遷移が実装されているので,戻るボタンや進むボタンを押してもページが再読込されるわけではありません。再読込するようにpushState/replaceStateを使わなければ良いかというとそういうわけでもなく,再読込したときにquery stringに保存されていないアプリケーションの状態が消えてしまいます。

解決法

解決策として以下のものを考えました。

  1. ReactTraining/history の listen を使う
  2. react-router の location を使う

1. はURLが変更された時に呼び出される history.listen を使う方法です。query stringに状態を保持したいページが複数ある場合(たとえばユーザの検索をするページとブログ記事の検索をするページ)などに
history.listen の中で pathname を使って条件分岐しないといけないかもしれません。

2. はreact-routerで withRouter で囲ったコンポーネントなどの props に渡される location を使う方法です。URLが変更されると location も変更されるのでReact Componentの componentWillReceiveProps というライフサイクルメソッドで変更を検知することができます。

サンプルプログラム

今回は 2. の方法を使ってサンプルを作成してみました。

https://arosh.github.io/funny/query-string.html?keyword=hello&order=DESC

ソースコードは以下に示します。 Home.js 以外は見なくてもよいです。

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import createStore from './createStore';

import App from './App';

const store = createStore();
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('react-root')
);
createStore.js
import { createStore } from 'redux';
// https://github.com/zalmoxisus/redux-devtools-extension
import { devToolsEnhancer } from 'redux-devtools-extension';
import reducer from './reducer';

export default () => createStore(reducer, devToolsEnhancer());
reducer.js
const SET_KEYWORD_AND_ORDER = 'SET_KEYWORD_AND_ORDER';

const initialState = {
  keyword: '',
  order: '',
};

export function setKeywordAndOrder(keyword, order) {
  return {
    type: SET_KEYWORD_AND_ORDER,
    payload: {
      keyword,
      order,
    },
  };
}

export default function(state = initialState, action) {
  switch (action.type) {
    case SET_KEYWORD_AND_ORDER:
      return {
        ...state,
        keyword: action.payload.keyword,
        order: action.payload.order,
      };
    default:
      return state;
  }
}
App.js
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import Home from './Home';

export default () => (
  <Router>
    <Home />
  </Router>
);
Home.js
import React from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { setKeywordAndOrder } from './reducer';

class Form extends React.Component {
  onClick = () => {
    this.props.push(this.keywordInput.value, this.orderSelect.value);
  };
  render = () => (
    <div>
      <form>
        <input
          type="text"
          ref={input => {
            this.keywordInput = input;
          }}
          placeholder="keyword"
        />
        <br />
        <select
          ref={select => {
            this.orderSelect = select;
          }}
        >
          <option value="ASC">昇順</option>
          <option value="DESC">降順</option>
        </select>
        <br />
        <button type="button" onClick={this.onClick}>
          検索
        </button>
      </form>
      <pre>
        {JSON.stringify({
          keyword: this.props.keyword,
          order: this.props.order,
        })}
      </pre>
    </div>
  );
}

class App extends React.Component {
  componentWillMount = () => {
    const params = new URLSearchParams(this.props.location.search);
    this.props.setKeywordAndOrder(params.get('keyword'), params.get('order'));
  };
  componentWillReceiveProps = nextProps => {
    // componentWillReceivePropsが無限に呼び出されるのを防ぐ
    if (nextProps.location !== this.props.location) {
      const params = new URLSearchParams(nextProps.location.search);
      const keyword = params.get('keyword');
      const order = params.get('order');
      this.props.setKeywordAndOrder(keyword, order);
    }
  };
  render = () => (
    <Form
      keyword={this.props.keyword}
      order={this.props.order}
      push={this.props.push}
    />
  );
}

export default withRouter(
  connect(
    state => ({
      keyword: state.keyword,
      order: state.order,
    }),
    (dispatch, ownProps) => ({
      push: (keyword, order) => {
        const { location, history } = ownProps;
        const params = new URLSearchParams(location.search);
        params.set('keyword', keyword);
        params.set('order', order);
        history.push({
          search: params.toString(),
        });
      },
      setKeywordAndOrder: (keyword, order) => {
        dispatch(setKeywordAndOrder(keyword, order));
      },
    })
  )(App)
);

動きを簡単に説明します。

  1. アプリケーションの状態が変更された場合
    • 検索ボタンの onClick が呼び出されたときにはReduxのStoreの状態を変更するのではなく history.push でURLを変更します
    • URL が変更されたら Appprops.location が変更されるので componentWillReceiveProps で変更を検知してReduxのStoreの状態を変更します
  2. query stringが指定されたURLにアクセスされた場合
    • componentWillMount でURLのquery stringから復元したい状態の情報を抜き出してReduxのStoreの状態を変更します
  3. ブラウザの進むボタンや戻るボタンが押された場合
    • URL が変更されたら Appprops.location が変更されるので componentWillReceiveProps で変更を検知してReduxのStoreの状態を変更します

アプリケーションの状態が変更された場合に,ReduxのStoreの状態を変更するのではなく,URLを変更して componentWillReceiveProps の中でStoreの状態を変更するのがトリッキーな気がしますが,setKeywordAndOrder が二重で呼び出されてしまうのを防ぐ簡単な方法を見つけることができませんでした。もっと簡単な方法をご存じの方はコメント欄で教えてください。また,もしかすると考慮するのを忘れていて不具合が出るパターンがあるかもしれないので,不具合を見つけた方も連絡をお願いします。

参考資料