30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

React Router v5.1 で導入された Hooks API について

React Router のv5.1で Hooks API が導入された。

reacttraining.com

URL パラメータやhistoryオブジェクトなどを、より簡単に取得できるようになった。
これまでの書き方も使えるが、今後のバージョンアップで非推奨になる可能性が高いとのことなので、今のうちに Hooks を使った書き方に慣れておいたほうがいいかもしれない。
個人的にはwithRouterを使わなくてもよくなるのが便利だと感じた。

この記事の内容は以下のバージョンで動作確認している。

  • react@16.10.2
  • react-router-dom@5.1.2

今回導入された API は以下の4つ。

  • useParams
  • useLocation
  • useHistory
  • useRouteMatch

コードを見ると、どれもReact.useContextを使って実装している模様。
react-router/hooks.js at v5.1.2 · ReactTraining/react-router

問題意識

上述のように、URL パラメータなどのルーティングに関する情報を取得する方法が、変わった。
「これまでと何が変わったのか」という視点で見たほうが分かりやすいので、まず従来の記法とその問題点を紹介することで、Hooks API が何を解決しているのかを説明する。

以下は、従来の記法で URL パラメータを取得している。
/component/xxxもしくは/render/xxxにアクセスすると、xxxの部分をpageIdとして認識して画面に表示する。

import React from 'react';
import {BrowserRouter, Switch, Route} from 'react-router-dom';

const Child = ({pageId}) => {
  return <p>This is child element in {pageId}</p>;
};

const ShowPageId = ({match}) => {
  return (
    <div>
      <p>Page id: {match.params.pageId}</p>
      <Child pageId={match.params.pageId} />
    </div>
  );
};

const App = () => (
  <BrowserRouter>
    <Switch>
      <Route path="/component/:pageId" component={ShowPageId} />
      <Route
        path="/render/:pageId"
        render={({match}) => <ShowPageId match={match} />}
      />
    </Switch>
  </BrowserRouter>
);
export default App;

従来は、ルーティングに関する情報をコンポーネントに渡すには、component属性かrender属性を使う必要があった。
だが、どちらも一長一短ある。
component属性を使うと、任意のpropsを渡すことが出来ない。render属性ならそれが可能だが、そうすると今度は、ルーティングに関する情報(上記の例ではmatch)も明示的に渡さなければならなくなる。
また、子要素(上記の例ではChild)にルーティングに関する情報を渡したいときは、その場合も明示的にpropsを渡さないといけない。

Hooks API はこれらの問題を解決する。

Hooks API ならルーティングに関する情報をどこからでも得られる

コンポーネントのなかでuseParamsを使えば、component属性やrender属性を使うことなく、URL パラメータを取得できる。

import React from 'react';
import {BrowserRouter, Switch, Route, useParams} from 'react-router-dom';

const Child = () => {
  const {pageId} = useParams();
  return <p>This is child element in {pageId}</p>;
};

const ShowPageId = ({originalProp}) => {
  const {pageId} = useParams();
  return (
    <div>
      <p>Page id: {pageId}</p>
      <p>{originalProp}</p>
      <Child />
    </div>
  );
};

const App = () => (
  <BrowserRouter>
    <Switch>
      <Route path="/component/:pageId">
        <ShowPageId originalProp="You can pass any parameter." />
      </Route>
    </Switch>
  </BrowserRouter>
);
export default App;

まず、Routeコンポーネントの書き方が変わる。component属性やrender属性で描画したいコンポーネントを指定するのではなく、Routeの子コンポーネントとして記述する。
そして当該コンポーネントのなかでuseParamsを使うことで、URL パラメータを取得できる。
ルーティングに関する情報を明示的にporpsとして渡す必要はないので、通常のコンポーネントとして扱える。任意のpropsを渡せるし、渡さなくてもよい。
useParamsを使えるのは、Route直下のコンポーネントだけではない。深い階層のコンポーネントであっても使える。上記の例では、ChilduseParamsを使っている。

useLocationuseHistoryも同じ要領で、locationオブジェクトとhistoryオブジェクトを取得するための API である。

import React from 'react';
import {
  BrowserRouter,
  Switch,
  Route,
  useLocation,
  useHistory,
} from 'react-router-dom';

const HooksSample = () => {
  const location = useLocation();
  const history = useHistory();
  console.log(Object.keys(location)); // ["pathname", "search", "hash", "state"]
  console.log(Object.keys(history)); // ["length", "action", "location", "createHref", "push", "replace", "go", "goBack", "goForward", "block", "listen"]
  return <div>Sample</div>;
};

const App = () => (
  <BrowserRouter>
    <Switch>
      <Route path="/component/:pageId">
        <HooksSample />
      </Route>
    </Switch>
  </BrowserRouter>
);
export default App;

なお、useHistoryは今後実装が予定されているuseNavigateの代用品のような位置付けらしい。今からuseHistoryを使っていくことで、同じ Hooks API であるuseNavigateに移行しやすくなる、ということだろう。

withRouter は非推奨になる可能性が高い

実は今までも、ルーティングに関する情報を明示的にコンポーネントに渡さなくても、withRouterを使えばそれを取得することが出来た。

import React from 'react';
import {BrowserRouter, Switch, Route, withRouter} from 'react-router-dom';

const ShowPageId = ({match}) => {
  return (
    <div>
      <p>Page id: {match.params.pageId}</p>
    </div>
  );
};
const WrappedShowPageId = withRouter(ShowPageId);

const App = () => (
  <BrowserRouter>
    <Switch>
      <Route path="/render/:pageId" render={() => <WrappedShowPageId />} />
    </Switch>
  </BrowserRouter>
);
export default App;

/render/xxxにアクセスすれば、URL パラメータを取得できていることを確認できる。

だが Hooks API が導入された今では、withRouterを使う積極的な理由はないはず。
しかも公式ブログによれば、今後のリリースで非推奨になる可能性が高いとのこと。

although withRouter is not deprecated in 5.1, it's a weird API when you can compose your state with hooks instead. It will also most likely be deprecated in a future release.

match オブジェクト等は同一性が保たれる

これは今回のバージョンアップで入ったものではなく以前からそうだったが、React Router から渡されるmatchオブジェクトなどは、コンポーネントがレンダリングされ直しても、同じ参照のオブジェクトが渡される。値が同じだけの別のオブジェクト、にはならない。

import React, {useState, useEffect} from 'react';
import {BrowserRouter, Switch, Route} from 'react-router-dom';

const paramsList = [];
const locationList = [];
const historyList = [];

const CountUp = props => {
  const [state, setState] = useState(0);

  const {match, location, history} = props;
  const {params} = match;

  useEffect(() => {
    paramsList.push(params);
    locationList.push(location);
    historyList.push(history);

    if (paramsList.length >= 2) {
      const prevIndex = paramsList.length - 2;
      const currentIndex = paramsList.length - 1;

      // いずれも常に true になる
      console.log(Object.is(paramsList[prevIndex], paramsList[currentIndex]));
      console.log(
        Object.is(locationList[prevIndex], locationList[currentIndex])
      );
      console.log(Object.is(historyList[prevIndex], historyList[currentIndex]));
    }
  });

  return (
    <div>
      <p>{state}</p>
      <button
        type="button"
        onClick={() => {
          setState(s => s + 1);
        }}
      >
        click
      </button>
    </div>
  );
};

const App = () => (
  <BrowserRouter>
    <Switch>
      <Route path="/component/:pageId" component={CountUp} />
    </Switch>
  </BrowserRouter>
);
export default App;

以下のように書き換えて Hooks API を使うようにしても、同じ結果になる。

@@ -1,15 +1,23 @@
 import React, {useState, useEffect} from 'react';
-import {BrowserRouter, Switch, Route} from 'react-router-dom';
+import {
+  BrowserRouter,
+  Switch,
+  Route,
+  useParams,
+  useLocation,
+  useHistory,
+} from 'react-router-dom';

 const paramsList = [];
 const locationList = [];
 const historyList = [];

-const CountUp = props => {
+const CountUp = () => {
   const [state, setState] = useState(0);

-  const {match, location, history} = props;
-  const {params} = match;
+  const params = useParams();
+  const location = useLocation();
+  const history = useHistory();

   useEffect(() => {
     paramsList.push(params);
@@ -47,7 +55,9 @@ const CountUp = props => {
 const App = () => (
   <BrowserRouter>
     <Switch>
-      <Route path="/component/:pageId" component={CountUp} />
+      <Route path="/component/:pageId">
+        <CountUp />
+      </Route>
     </Switch>
   </BrowserRouter>
 );

そのためmatchオブジェクトなどは、そのままでuseEffectdepsなどに使うことが出来る。

useRouteMatch

useRouteMatchは他の Hooks API とは使い方が異なり、Routeコンポーネントの代わりに使う。

以下のコードでは、Routeコンポーネントを使ってAdminMenuの表示を制御している。
例えば/user/1にアクセスしたときは共通メニューしか表示されないが、admin/1にアクセスすると管理者限定メニューも表示される。

import React from 'react';
import {BrowserRouter, Route} from 'react-router-dom';

const AdminMenu = ({match}) => {
  return (
    <>
      <h2>管理者限定メニュー</h2>
      <p>管理者ID: {match.params.adminId}</p>
      <ul>
        <li>ユーザー追加</li>
        <li>ユーザー削除</li>
      </ul>
    </>
  );
};

const Menu = () => {
  return (
    <div>
      <h1>Menu</h1>
      <h2>共通メニュー</h2>
      <ul>
        <li>データ閲覧</li>
        <li>データ編集</li>
      </ul>
      <Route path="/admin/:adminId" component={AdminMenu} />
    </div>
  );
};

const App = () => (
  <BrowserRouter>
    <Menu />
  </BrowserRouter>
);
export default App;

同じことをuseRouteMatchを使って実装したのが以下。

@@ -1,5 +1,5 @@
 import React from 'react';
-import {BrowserRouter, Route} from 'react-router-dom';
+import {BrowserRouter, useRouteMatch} from 'react-router-dom';

 const AdminMenu = ({match}) => {
   return (
@@ -15,6 +15,9 @@ const AdminMenu = ({match}) => {
 };

 const Menu = () => {
+  const match = useRouteMatch({
+    path: '/admin/:adminId',
+  });
   return (
     <div>
       <h1>Menu</h1>
@@ -23,7 +26,7 @@ const Menu = () => {
         <li>データ閲覧</li>
         <li>データ編集</li>
       </ul>
-      <Route path="/admin/:adminId" component={AdminMenu} />
+      {match && <AdminMenu match={match} />}
     </div>
   );
 };

v6 への準備

v5.1では、これまでの書き方も問題なく使える。だが今後のリリースで非推奨になる可能性が高い。
Hooks API を積極的に使うことは、今後のメジャーバージョンアップへの準備になる。

Although they are not deprecated in 5.1, the and APIs have several quirks that just aren't needed (see the discussion in useParams above) in a world with hooks. We will most likely deprecate these APIs in a future release.

両方の書き方を使えるv5.1で漸進的に準備を進めておくと、よいかもしれない。

参考資料