JavaScript
React
react-router
redux
react-redux

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

問題

react-routerreact-redux を使っているときにありがちな,「URLは変わるんだけどコンポーネントが再描画されない」 というトラブル。

App.js
ReactDOM.render(
  <Provider store={store}>
    <Router>
      <Switch>
        <Route component={Timeline} path="/articles" />
      </Switch>
    </Router>
  </Provider>,
  document.getElementById('root'),
)
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)
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() の最適化が悪さをしています。

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

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

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 を受け取ればURLの変更を検知できるようになります。

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() でラップする必要があります。この関数は <Route /> で引数をラップすることをやってくれるだけのシンプルな実装です。

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 を使う場合はストアから直接 location を受け取ることができるので,無駄なHOCが削減できます。

App.js
  ReactDOM.render(
    <Provider store={store}>
-     <Router>
+     <ConnectedRouter history={history}>
        <Switch>
          <Route component={Timeline} path="/articles" />
        </Switch>
-     </Router>
+     </ConnectedRouter>
    </Provider>,
    document.getElementById('root')
  )
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
unimplement shouldComponentUpdate? · Issue #507 · reactjs/react-redux