ReduxとReact Routerの相性が悪いのでrouterを一から書いてみた

こんにちは、エンジニアの建三です。

Reactと一緒に使うRouterと言えばReact Routerですよね。しかし僕のReact+ReduxアプリにReact Routerを導入してみると、どうも思うようにいきませんでした。

Reduxは"Single Source of Truth"をモットーにしており、Reduxのstoreがアプリのstateを全て管理しています。しかしReact Routerを使うと、React Routerがrouteを管理しReduxがそれ以外を管理するというぎこちない感じになります。

React Routerの代わりを探す旅に出た

そんなぎこちなさをGoogleにぶつけてみると、同じような考えを持ってる人が沢山いました。React Router v4が出た時のHacker NewsではReact Routerの批判のコメントが多く見られ、代替案が多数提示されました。

React Router以外のRouterを調べてみると沢山出てきますが、どれも使い方がイマイチ分からなく断念...

するとこのブログで「そもそもroutingにlibraryなんているの?」というのが書かれており、読んでみました。早速試してみると少しのコードでrouterが自分で作れ、「これでいいじゃん」と思いました。

ただ先ほどのブログでは色んな詳細が省かれており、Reduxの話が全くされていません。Reduxの話はこちらのブログが参考になりました。一つ一つのrouteをreduxのアクションにしてしまおうという非常にシンプルな方法です。RouteをJSXで定義するReact Routerに比べこちらの方がフレキシブルだししっくりくるなと思いました。

Routerを作ってみよう!

必要なlibraryはhistoryだけです。これはブラウザーWindow.historyとほぼ同じAPIですが、ブラウザーによる違いをなくすために、Window.historyを直接使わずこっちを使います。React Routerもhistoryを使っています。

まずはインストールします。

$ npm install --save history

次にメインのrouterを作ります。基本的な使い方は上記のリンクを参照して下さい。 URLが変わる度にhistory.listenが呼ばれます。 location.pathnameがその名の通りpathになるので、それを好きなように処理してReduxにdispatchします。

import createHistory from 'history/createBrowserHistory'
import store from '../store.js'

export const history = createHistory()

function handleNavigation(location, action) {
  // e.g., 'examples/123/' => ["examples", "123"]
  const pathList = location.pathname.split('/').filter(o => o !== '')
  // examplesをrootに指定
  const path = pathList[0] || 'examples'

  else if (path === 'examples') {
    store.dispatch({type: 'ROUTE_EXAMPLES'})
  }
  else if (path === 'guide') {
    store.dispatch({type: 'ROUTE_GUIDE'})
  }
  else if (path === 'projects') {
    store.dispatch({type: 'ROUTE_PROJECTS'})
  }
}

handleNavigation(history.location)
history.listen(handleNavigation)

reducerでそれぞれのアクションを処理します。ただのstatic pageの移動だったら以下のようにrouteを変えるだけで十分ですが、勿論ここで他のstateを変えることが出来ます。

function reducer(state, action) {
  switch (action.type) {
    case 'ROUTE_EXAMPLES': {
      return {
        ...state,
        route: 'examples',
      }
    }
    case 'ROUTE_GUIDE': {
      return {
        ...state,
        route: 'guide'
      }
    }
    case 'ROUTE_PROJECTS': {
      return {
        ...state,
        route: 'projects'
      }
    }
    // ...
  }
}

そしてrouteによってコンポーネントを切り替えます。

import React from 'react'
import store from '../store.js'
import { Link } from './my-router'

import Examples from './Examples'
import Projects from './Projects'
import Guide from './Guide'

function getChildren(route) {
  if (route === 'projects') {
    return <Projects/>
  }
  if (route === 'examples') {
    return <Examples/>
  }
  if (route === 'guide') {
    return <Guide />
  }
}

const App = React.createClass({
  componentDidMount: function() {
    store.subscribe(() => this.forceUpdate())
  },
  render: function() {
    const state = store.getState()
    const route = state.route
    const children = getChildren(route)

    return (
      <div>
        <nav className="navbar navbar-default">
          <div className="container-fluid">
            <div className="navbar-header">
              <Link to='/' className="navbar-brand">Geeklish</Link>
            </div>

            <div className="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
              <ul className="nav navbar-nav">
                <li><Link to='/examples'>Examples</Link></li>
              </ul>
              <ul className="nav navbar-nav">
                <li><Link to='/projects'>My Projects</Link></li>
              </ul>
              <ul className="nav navbar-nav">
                <li><Link to='/guide'>Guide</Link></li>
              </ul>
            </div>
          </div>
        </nav>

        {children}

        <footer>
        </footer>
      </div>
    )
  }
})

上記にLinkコンポーネントがありますが、これも基本的なものであれば簡単に作れます。React Routerと同じAPIにしています。

import React from 'react'

const isLeftClickEvent = (e) => e.button === 0
const isModifiedEvent = (e) => !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)

export const Link = React.createClass({
  handleClick: function(event) {
    if (event.defaultPrevented || isModifiedEvent(event) || !isLeftClickEvent(event)) {
      return
    }
    event.preventDefault()
    history.push(this.props.to)
  },

  render: function() {
    let props = Object.assign({}, this.props)
    props.onClick = this.handleClick
    props.href = this.props.to

    return (
      <a {...props}>{this.props.children}</a>
    )
  }
})

たったこれだけでrouterの完成です!

大規模なアプリになるともっとちゃんとしたコードを書かなければいけないですが、最初のうちはこれで十分です。

上記に書いた通りhistory APIブラウザーに入っており、それを使うだけで誰でもrouterが作れます。やはりフロントエンドエンジニアはブラウザーを知ることが重要だなというのを今回学びました。

僕が作ってるアプリはまだrouteが複雑じゃないし、ほぼSingle Page Applicationなので、このまま自家製routerを使っていきます。

Reactを使ってる方には是非一度routerを自分で作ることをオススメします。数時間で書けますし、React Routerや他のroutingのlibraryをより理解出来るようになります。

参考資料

https://news.ycombinator.com/item?id=12511419 http://beautifulcode.1stdibs.com/2016/09/20/redux-url-router/ http://jamesknelson.com/even-need-routing-library/ https://github.com/mjackson/history