react-router + react-redux で再描画されないトラブルの対処法

# 問題

[react-router](https://github.com/ReactTraining/react-router) と [react-redux](https://github.com/reactjs/react-redux) を使っているときにありがちな，**「URLは変わるんだけどコンポーネントが再描画されない」** というトラブル。

```jsx:App.js
ReactDOM.render(
  <Provider store={store}>
    <Router>
      <Switch>
        <Route component={Timeline} path="/articles" />
      </Switch>
    </Router>
  </Provider>,
  document.getElementById('root'),
)
```

```jsx:Timeline.js
const Timeline = ({ articles }) => (
  <div>
    <ul>
      {articles.map(({ title, body }) => (
        <li key={article.id}>
          <h1>{title}</h1>
          <p>{body}</p>
        </li>
      ))}
    </ul>
    <Route component={ArticleDetail} path={'/articles/:articleId'} />
    /* ↑ IDが指定されたときだけタイムラインの上に特定記事の詳細画面をオーバーレイ */
  </div>
)

export default connect(
  ({ articles }) => ({ articles: articles.data }),
)(Timeline)
```


```jsx:ArticleDetail.js
const ArticleDetail = ({ title, body, ...otherProps 　}) => (
  <div>
    <h1>{title}</h1>
    <p>{body}</p>
    <Link to='/articles/:articleId/favorites'>いいね一覧</Link>
    /*
      いいね一覧表示ロジックを書く （省略）
    */
  </div>
)

export default connect(
  ({ articles }, { match: { params } }) => selectArticleById(articles.data, params.articleId),
)(ArticleDetail)
```

- 親 `<Route />` で指定しているコンポーネントの中に更に子 `<Route />` がある
- **子 `<Route />` も `connect()` でラップされている**

こういう状況のとき，例えば「いいね一覧」をクリックして **`Timeline` には一切影響を与えずに `ArticleDetail` 内のみの再描画を行わせる** という処理を行おうとしても，一向に再描画が行われなくなります。

# 原因

react-redux の `connect()` の最適化が悪さをしています。

```jsx:connect()でラップされたHOCが使用するshouldComponentUpdate
shouldComponentUpdate() {
  return this.selector.shouldComponentUpdate
}
```

この `this.selector.shouldComponentUpdate` の算出にはプロパティの比較結果が使われます。

```jsx:stateProps,ownProps,mergedPropsが等しいかどうかの比較に使われるshallowEqual関数の実装
const hasOwn = Object.prototype.hasOwnProperty

function is(x, y) {
  if (x === y) {
    return x !== 0 || y !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export default function shallowEqual(objA, objB) {
  if (is(objA, objB)) return true

  if (typeof objA !== 'object' || objA === null ||
      typeof objB !== 'object' || objB === null) {
    return false
  }

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) return false

  for (let i = 0; i < keysA.length; i++) {
    if (!hasOwn.call(objB, keysA[i]) ||
        !is(objA[keysA[i]], objB[keysA[i]])) {
      return false
    }
  }

  return true
}
```

コンポーネントに渡ってくるプロパティが `shallowEquals` 関数を使って比較され，等しいと判断された場合には再描画が行われなくなります。

- 子はURLの変更で受け取るプロパティが変化するので再描画されるべき
- 親はURLが変更されても受け取るプロパティが変化しないので再描画されない
- 結果，子も再描画されない

という流れです。

# 対策

## `connect()` したコンポーネントの中で `connect()` しない

理想。ですが依存ライブラリも考慮した設計上 `connect()` せざるを得ない場所もあるので，銀の弾丸とはなりません。

## `location` を受け取る

公式で紹介されている対処法です。 `<Route />` は `match` `location` `history` を描画するコンポーネントに渡しますが，この [`location`](https://github.com/ReactTraining/react-router/blob/76e7c81e8605f267dd26833e23a2d24552d24110/packages/react-router/docs/api/location.md) を受け取ればURLの変更を検知できるようになります。

```diff:Timeline.js
  const Timeline = ({ articles }) => (
    <div>
      <ul>
        {articles.map(({ title, body }) => (
          <li key={article.id}>
            <h1>{title}</h1>
            <p>{body}</p>
          </li>
        ))}
      </ul>
      <Route component={ArticleDetail} path={'/articles/:articleId'} />
      /* ↑ IDが指定されたときだけタイムラインの上に特定記事の詳細画面をオーバーレイ */
    </div>
  )
  
  export default connect(
-   ({ articles }) => ({ articles: articles.data }),
+   ({ articles }, { location }) => ({ articles: articles.data, location }),
  )(Timeline)
```

たまたまこの `Timeline` は直属の親コンポーネントが　`<Route />` なので問題ありませんが，もしそうではない場合， [`withRouter()`](https://github.com/ReactTraining/react-router/blob/76e7c81e8605f267dd26833e23a2d24552d24110/packages/react-router/docs/api/withRouter.md) でラップする必要があります。この関数は `<Route />` で引数をラップすることをやってくれるだけのシンプルな実装です。

```diff:Timeline.js
  const Timeline = ({ articles }) => (
    <div>
      <ul>
        {articles.map(({ title, body }) => (
          <li key={article.id}>
            <h1>{title}</h1>
            <p>{body}</p>
          </li>
        ))}
      </ul>
      <Route component={ArticleDetail} path={'/articles/:articleId'} />
      /* ↑ IDが指定されたときだけタイムラインの上に特定記事の詳細画面をオーバーレイ */
    </div>
  )

- export default connect(
-   ({ articles }) => ({ articles: articles.data }),
- )(Timeline)
+ export default withRouter(connect(
+   ({ articles }, { location }) => ({ articles: articles.data, location }),
+ )(Timeline))
```

[react-router-redux](https://github.com/reactjs/react-router-redux) を使う場合はストアから直接 `location` を受け取ることができるので，無駄なHOCが削減できます。

```diff:App.js
  ReactDOM.render(
    <Provider store={store}>
-     <Router>
+     <ConnectedRouter history={history}>
        <Switch>
          <Route component={Timeline} path="/articles" />
        </Switch>
-     </Router>
+     </ConnectedRouter>
    </Provider>,
    document.getElementById('root')
  )
```

```diff:Timeline.js
  const Timeline = ({ articles }) => (
    <div>
      <ul>
        {articles.map(({ title, body }) => (
          <li key={article.id}>
            <h1>{title}</h1>
            <p>{body}</p>
          </li>
        ))}
      </ul>
      <Route component={ArticleDetail} path={'/articles/:articleId'} />
      /* ↑ IDが指定されたときだけタイムラインの上に特定記事の詳細画面をオーバーレイ */
    </div>
  )
  
  export default connect(
-   ({ articles }) => ({ articles: articles.data }),
+   ({ articles, routing: { location } }) => ({ articles: articles.data, location }),
  )(Timeline)
```

# 参考

[react-router/blocked-updates.md at 76e7c81e8605f267dd26833e23a2d24552d24110 · ReactTraining/react-router](https://github.com/ReactTraining/react-router/blob/76e7c81e8605f267dd26833e23a2d24552d24110/packages/react-router/docs/guides/blocked-updates.md)
[unimplement shouldComponentUpdate? · Issue #507 · reactjs/react-redux](https://github.com/reactjs/react-redux/issues/507)