JavaScript
reactjs
react-router

react-router@v4を使ってみよう:シンプルなtutorial

概要

Reactでルーティングをするためのライブラリであるreact-routerのv4の基本的な使い方を覚えるために、簡単なwebページを作ってみます。

react-routerのv4というと、v2やv3から破壊的な変更があり、かなり使いやすくなったとの評判なのですが、私はv4以前のreact-routerを使ったことはなかったので、この記事には過去のversionとの比較という観点はありません。初めて使う人が、ルーティングの概念やreact-routerの使い方がわかるチュートリアルになれば良いなと思いながら書きました。

方針としては、とにかくシンプルに小さなwebページを作ることにします。複雑な動作やサーバーとの通信はしませんし、cssもほぼ書きません。この記事はreact-routerを勉強し始めた当日に書いているので、間違っている内容があるかもしれません。ご指摘ください。

なぜreact-routerを使う必要があるのか

ReactでふつうにSPAを書くと、描画されるコンポーネントが変わって画面が遷移してもURLは変わらないと思います。つまり、URLとアプリの状態が関連づいていない状況になります。この2つを関連づけて、URLからアプリ内の特定の状態にアクセスできるようにしたり、逆にアプリ内での状態変化をURLに反映させたりすることをルーティングと言います。ルーティングをすることによって、ブラウザの戻るボタンが使えるようになったり、特定のURLを打ち込むことで特定のページに直接アクセスできるようになるというメリットがあります。

reactでルーティングをするためのデファクトのライブラリがreact-routerです。

完成品

キャラクターに人気投票するページを作りましょう。ReactのチュートリアルでよくあるCounterの例とルーティングを組み合わせたものと考えると良いと思います。以下にアニメgifを貼っておきますが、描画しているコンテンツとURLが連動しており、またブラウザの戻る/進むボタンが機能しているのがわかると思います。

4.gif

コードとデモのサイトは以下にあります。

にあります。

環境

  • Mac OS X 10.12.3
  • node 7.10.0
  • npm 4.2.0
  • create-react-app 1.3.1

ライブラリのバージョンは以下で示すpackage.jsonを見てください。

とりあえずアプリの形をつくる

とりあえずReactが動く環境が必要なので、簡単のためcreate-react-appを使います(使いたくない人は以下適宜読みかえてください)。

// create-react-appをインストールしていない人は
// npm i -g create-react-app

create-react-app react-router-v4-tutorial
cd react-router-v4-tutorial

react-routerをインストールします。react-routerにはいくつかのパッケージに分けられているのですが、公式のgithubを見ると、ブラウザで動くページを書くにはreact-router-domを使えばいいことがわかります。react-routerはコアの機能を担っているのですが、react-router-domreact-routerのexportも含めてexportしてくれるようなので、react-routerをインストールする必要はありません。

npm install -S react-router-dom

この時点でpackage.jsonは以下のようになっています。

package.json
{
  "name": "react-router-v4-tutorial",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^15.6.1",
    "react-dom": "^15.6.1",
    "react-router-dom": "^4.1.1"
  },
  "devDependencies": {
    "react-scripts": "1.0.7"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  }
}

create-react-appで生成された余計なファイルがいっぱいあるので、いったんsrcの中のファイルをすべて消して0から書くことにしましょう。

cd src
rm *
touch index.js App.js

まずふつうにindex.jsを書きます。

index.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(
  <App />,
  document.getElementById('root')
)

次にApp.jsです。今回は、すべてのコンポーネントをApp.jsの中に書くことにします。とりあえず、複雑なロジックは無視してルーティングの骨格の部分のみ書きましょう。Home, About, Friendsの3つのページを用意します。

App.js
import React, { Component } from 'react'
import { BrowserRouter, Route, Link } from 'react-router-dom'

const App = () => (
  <BrowserRouter>
    <div>
      <Route exact path='/' component={Home} />
      <Route path='/about' component={About} />
      <Route path='/friends' component={Friends} />
    </div>
  </BrowserRouter>
)

const Home = () => (
  <div>
    <h2>Home</h2>
    <p>Welcome to ようこそ</p>
  </div>
)
const About = () => (
  <div>
    <h2>About</h2>
    <p>フレンズに投票するページです</p>
  </div>
)
const Friends = () => (
  <div>
    <h2>Friends</h2>
    <p>ここにフレンズのリストを書きます</p>
  </div>
)

export default App

実際にサイトを開いてみましょう。

npm start

して、ブラウザでlocalhost:3000にアクセスします。ここで、まずは<Home>コンポーネントが開き、URLをlocalhost:3000/aboutに変更すると<About>が、さらにlocalhost:3000/friendsに変更すると<Friends>が開くことが確認できると思います。これで、URLとページに表示する内容を対応させる、基本的なルーティングができました。

このルーティングを実現しているのが、App.jsの中で呼ばれている<BrowserRouter><Route>です。<BrowserRouter>の中にいくつか<Route>を書き、pathに対応させたいURLを、componentに描画したいコンポーネントを渡します。ここで、exactを設定しない限り、pathが入力したURLに前方一致していれば描画される(つまりlocalhost:3000/以下のすべてのURLに対して<Home>が描画されてしまう)ため、<Home>にはexactを設定しています。

もっと詳しい情報については以下のドキュメントをどうぞ。

さて、ここまでで完成したサイトはページの遷移の際にURLを直接打ち込まなければならず、明らかに不便です。それぞれのページに対するリンクを用意するために、<App>を書き換えましょう。

App.js
const App = () => (
  <BrowserRouter>
    <div>
+     <ul>
+       <li><Link to='/'>Home</Link></li>
+       <li><Link to='/about'>About</Link></li>
+       <li><Link to='/friends'>Friends</Link></li>
+     </ul>

+     <hr />
+     
      <Route exact path='/' component={Home} />
      <Route path='/about' component={About} />
      <Route path='/friends' component={Friends} />
    </div>
  </BrowserRouter>
)

<Link>要素のtoに遷移させたいpathを設定しておくことで、クリックされたとき自動で遷移してくれるようになります。これについてもドキュメントを貼っておきます。

ここでページの動きを見てみましょう。

1.gif

リンクもうまく動いていますし、ブラウザの戻るボタンもちゃんと機能していますね。

2段階のルーティング

基本的なルーティングは以上で終わりです。もう少し複雑なことをしてみましょう。friendsにアクセスしたときに、キャラクターのリストを表示して、かつそのリストからそれぞれのキャラクターのページに飛べるようにします。

まずは、表示したいキャラクターのリストを用意します。

App.js
const FRIENDS = [
  {
    id: 'serval',
    nameJa: 'サーバル',
    nameEn: 'Serval Cat',
    family: 'ネコ目ネコ科ネコ属'
  },
  {
    id: 'raccoon',
    nameJa: 'アライグマ',
    nameEn: 'Common raccoon',
    family: 'ネコ目アライグマ科アライグマ属'
  },
  {
    id: 'fennec',
    nameJa: 'フェネック',
    nameEn: 'Fennec',
    family: 'ネコ目イヌ科キツネ属'
  }
]

const friendById = id => FRIENDS.find(friend => friend.id === id)

それぞれの要素にidを用意し、あとでこのidをそれぞれのキャラクターのページにアクセスするpathとして使うことにします。つまり、例えばキャラクター「サーバル」のidservalになっているので、localhost:3000/friends/servalでサーバルのページに飛ぶようにするということです。また、idをもとにリストから対応するキャラクターのオブジェクトを探すfriendByIdという関数を書いておきました。

それでは、<Friends>コンポーネントを書き換えます。

App.js
const Friends = () => (
  <div>
    <h2>Friends</h2>
-   <p>ここにフレンズのリストを書きます</p>
+   <Route exact path='/friends' component={FriendList} />
+   <Route path='/friends/:id' component={Friend} />
  </div>
)

ここで、<Route>で表示される<Friends>の中にさらにもう1段階<Route>を作って表示をわける必要があるのですが、上記のようにそのままふつうに書けば問題ありません。まず、正確にlocalhost:3000/friendsにアクセスした場合は、<FriendList>を表示し、さらにその後ろにキャラクターのidをつけた場合、対応するキャラクターのページを表示するようにしています。

ここで、friends/:idのようにコロンをつけることによって、その部分に入力された文字列を変数として受け取って描画できるコンポーネント内で使えるようになります。それでは、そのidを使う部分も含めて、<FriendList><Friend>を書いていきます。

App.js
const FriendList = () => (
  <div>
    {FRIENDS.map(friend => (
      <li key={friend.id}>
        <Link to={`/friends/${friend.id}`}>{friend.nameJa}</Link>
      </li>
    ))}
  </div>
)

const Friend = props => {
  const { id } = props.match.params
  const friend = friendById(id)

  if (typeof friend === 'undefined')  {
    return (
      <div>
        <p>Friends with id '{id}' does not exist.</p>
      </div>
    )
  }


  const containerStyle = { border: '1px gray solid', display: 'inline-block', padding: 10 }
  const contentsStyle = { margin: 0 }

  return (
    <div>
      <div style={containerStyle}>
        <p style={contentsStyle}>{friend.family}</p>
        <h1 style={contentsStyle}>{friend.nameJa}</h1>
        <p style={contentsStyle}>{friend.nameEn}</p>
      </div>
    </div>
  )
}

まず、<FriendList>では、配列FRIENDSmapして、それぞれのキャラクターのページへの<Link>を貼っています。

<Friend>の動作は少し複雑です。まず、このコンポーネントではpropsを受け取っています。<Friends>のコードを見ると、<Friend>はなにもpropsを受け取っていないように見えるのですが、実はすべての<Route>は自動的にいくつかのpropsを受け取ります。

今回は、その中のmatchを使います。実は、match.params.idの中に、<Friends>内で<Friend>にアクセスするときのpath/friends/:id:idの部分の文字列が入っているのです。

そのため、この受け取ったidをもとにfriendのオブジェクトを探し出し、それを描画に使うことで対応するキャラクターの画面を表示することができます。

たとえば/friends/servalにアクセスしたときには、<Friend>内でのidservalになり、friendByIdによってfriendには配列FRIENDSの要素である以下のオブジェクトが入ることになります。

{
  id: 'serval',
  nameJa: 'サーバル',
  nameEn: 'Serval Cat',
  family: 'ネコ目ネコ科ネコ属'
}

コードを見ると、render()の内部で、上記のオブジェクトがうまく描画に使われていることがわかると思います。

また、

App.js
if (typeof friend === 'undefined')  {
  return (
    <div>
      <p>Friends with id '{id}' does not exist.</p>
    </div>
  )
}

の部分は存在しないidにアクセスされたときのためのエラー処理です。例えば、ブラウザでlocalhost:3000/dogにアクセスすると、FRIENDS内にiddogの要素がないことからfriendundefinedになってしまうので、その際に別の画面を表示するようにしています。

それでは、ページを見てみましょう。

2.gif

うまく動いています。

Reactの機能を使う

ここで終わっても良いのですが、冷静に考えてここまではReactの機能をなにも使っておらず、単にルーティングをして静的なコンポーネントを返しているだけです。せっかくなので、Reactを使って動的なページにしてみましょう。

今回は、<FriendList>からキャラクターに投票できるようにして、各<Friend>のページで投票数を表示するようにします。

まずは、<Friends>以下を書き換えます。ここで、<Friends>に投票数の状態を持たせたいため、既存の<Friends>を消して、関数ではなくクラスとして0から書き直すことにします。

App.js
class Friends extends Component {
  constructor() {
    super()
    this.state = {}
    this.handleVote = this.handleVote.bind(this)
  }

  componentWillMount() {
    FRIENDS.forEach(friend => {
      this.setState({
        ...this.state,
        [friend.id]: 0
      })
    })
  }

  handleVote(id) {
    this.setState({
      [id]: this.state[id] + 1
    })
  }

  render() {
    return (
      <div>
        <h2>Friends</h2>
        <Route exact path='/friends' render={props => <FriendList handleVote={this.handleVote} />} />
        <Route path='/friends/:id' render={props => <Friend match={props.match} votes={this.state} />} />
      </div>
    )
  }
}

かなり大きな変更があります。まず、constructor()componentWillMount()内でstateを初期化しています。とくにcomponentWillMount()内は複雑なコードに見えますが、冷静に見てみると、単にstate

this.state = {
  serval: 0,
  raccoon: 0,
  fennec: 0
}

としているのと同じことです(最初からそう書けという気もしますが、今後FRIENDSが増えてもコードの変更をしなくて良いようにしています)。このstateは、各キャラクターへの投票数を表しています。また、イベントハンドラのhandleVoteで、投票に応じてstateにおいて対応するキャラクターの投票数を1増やすようにしています。

さて、投票のボタンを<FriendList>に置いて、投票数を<Friend>内に表示したいので、それぞれhandleVote<FriendList>に、this.state<Friend>に渡したいです。ただ、上でも述べたように<Route>には、matchなどデフォルトで渡されるpropsがあり、追加のpropsを渡すことができないようになっています(たぶん)。そのため、<Route>を、

App.js
- <Route exact path='/friends' component={FriendList} />
- <Route path='/friends/:id' component={Friend} />
+ <Route exact path='/members' render={props => <FriendList handleVote={this.handleVote} />} />
+ <Route path='/members/:id' render={props => <Friend match={props.match} votes={this.state} />} />

のように書き換えています。実は、<Route>に描画するコンポーネントを渡すにはcomponent以外にも、renderchildrenという方法があり、ここではrenderを使っています。

renderには、「matchなどが含まれるデフォルトのpropsを引数にとって、描画したいコンポーネントを返す関数」を渡すことにより描画をします。<FriendList>ではデフォルトのpropsを使っていないことからhandleVoteのみを渡し、<Friend>ではmatchを使うのでmatchと投票数this.statevotesとして渡しています。

それでは、<FriendList><Friend>を見ていきましょう。これらは、渡されたpropsをうまく使ってあげれば良いだけなので小さな変更です。

App.js
- const FriendList = () => (
+ const FriendList = props => (
  <div>
    {FRIENDS.map(friend => (
      <li key={fiend.id}>
        <Link to={`/friends/${friend.id}`}>{friend.nameJa}</Link>
+       <button onClick={() => props.handleVote(friend.id)}>+</button>
      </li>
    ))}
  </div>
)

const Friend = props => {
  const { id } = props.match.params
  const friend = friendById(id)
+ const vote = props.votes[id]

  if (typeof friend === 'undefined')  {
    return (
      <div>
        <p>Friends with id '{id}' does not exist.</p>
      </div>
    )
  }

  const containerStyle = { border: '1px gray solid', display: 'inline-block', padding: 10 }
  const contentsStyle = { margin: 0 }

  return (
    <div>
      <div style={containerStyle}>
        <p style={contentsStyle}>{friend.family}</p>
        <h1 style={contentsStyle}>{friend.nameJa}</h1>
        <p style={contentsStyle}>{friend.nameEn}</p>
      </div>
+     <h1>Vote: {vote}</h1>
    </div>
  )
}

それでは、ページの動きを見てみます。

3.gif

ということで、無事投票ができています。以上でこのページは完成とします。

※今回は、簡単のため投票数を<Friends>コンポーネントで管理していますが、これだと<FriendList>で投票してから一旦違うページ(例えば<About>)に飛ぶと投票数の情報が失われてしまいます。この問題は、情報をさらに持ち上げて<App>stateで管理するか、reduxを使ってstoreで管理するなどにより解決します。

今回使っていないAPI

react-routerの機能のうち、今回使っていないものをいくつか紹介します。それぞれについて、公式のドキュメントと使用例を貼っていきます。

<Route children={...}>

<Route>にコンポーネントを渡す方法でcomponentrenderを紹介しましたが、もう一つchildrenというやり方があります。使い方はrenderと同じのようですが、こちらは<Route>がURLにmatchしないとき、props.matchnullになるという違いがあるようです。

<NavLink>

<NavLink><Link>と同じ働きをしますが、描画されているコンポーネントに対応するリンクにスタイリングをすることができます。今回作ったページでも、<App>の中の<Link>

<ul>
  <li><NavLink activeStyle={{ color: 'red' }} exact to='/'>Home</NavLink></li>
  <li><NavLink activeStyle={{ color: 'red' }} to='/about'>About</NavLink></li>
  <li><NavLink activeStyle={{ color: 'red' }} to='/friends'>Friends</NavLink></li>
</ul>

のように変更するだけで、現在のURLに対応するリンクをスタイリングしてくれます。

<Redirect>

例えばログインしていないユーザーとログインしているユーザーで別のコンテンツを描画したいときとかに使えるっぽいです。

<Switch>

今回は複数の<Route>を並べるとき単に

<Route ...>
<Route ...>
<Route ...>

と書いていましたが、これを<Switch>を使って

<Switch>
  <Route ...>
  <Route ...>
  <Route ...>
</Switch>

と書くことができます。これによって、<Route>が排他的に描画されるようになります。すなわち、URLが複数の<Route>にmatchしている場合でも、ただ一つだけ一番上の<Route>が描画されます。これによって、意図せず複数の<Route>が描画されてしまうのを防げることに加えて、以下の例のようにmatchする<Route>がない場合のデフォルトの<Route>を作れたり、path中のパラメータを使った柔軟なルーティングができるようになります。

reduxとの併用

Reactを使うとなると、reduxも使いたい場合がほとんどだと思うんですが、下記のドキュメントによるとけっこうややこしいっぽいです。

正直あまりよくわからなかったのでいろいろと不正確な可能性が高いですが、一応まとめると、

だいたいの場合は単にreduxを使ってもうまくいく(その場合reduxstoreにはルーティングの情報はなく、それ以外のデータのみ管理することになる(?))。しかし、

  • connectを使う場合
  • <Route component={Component}>以外の形で描画する場合

はうまくいかない(ルーティングの情報が変わっても、storeが変わらない限りshouldComponentUpdateが呼ばれないから)。ただ、それらの場合にも以下のようwithRouterを使ってあげればうまく動く。

// NG
export default connect(mapStateToProps)(Something)

// OK
import { withRouter } from 'react-router-dom'
export default withRouter(connect(mapStateToProps)(Something))

ただし、上記のOKの場合でも、以下のようなことはできない。

  • storeとルーティングのデータを同期したり、storeからルーティングを操作したりする
  • dispatchによってページ遷移をする
  • ページ遷移についてもRedux devtoolのtime travel debuggingをする

どうしても上記のようなことをしたい場合は、react-router-reduxを使えば良い。

とのことです。間違っているところをご指摘いただけるとありがたいです。とりあえずよくわからないので、気が向いたらreduxとの組み合わせについても調べたいと思います。

以上、よろしくお願いします。