Router が壊れた
年末に React 案件の package をアップグレードしたら動かなくなった。具体的には、react-router-dom v5.2 -> v6.2 にしたら大量のエラーが発生した。公式ドキュメントを当たると、どうやら現状そのままのコードでは動かないようだ。
React の Router は基本、過去に作ったものを使いまわしていて、その元になったのも、Udemy かどこかで勉強した時のサンプルコードそのまんまで、ふ〜ん程度にしか理解していなかったので、良い機会なので、取り組むことにした。
v6アップグレードガイド
Upgrading from v5 - React Router
ひとまず気まぐれに翻訳した > v6アップグレードガイド
まず、注意点として、
- 後方互換パッケージ作ってて、そのままでも動くようにするからちょっと待ってて
-
<Prompt>
にはまだ非対応
と書いてある。Prompt を使っている、大規模なルートがあるなどの場合は、待つのが良さそう。どちらにも該当しないので、ガイドに沿ってアップグレードすることにした。
手順として、
- React v16.8 以上にアップグレードする
- React Router を v5.1 にアップグレードする
-
<Switch>
内の<Redirect>
を削除する - カスタム
<Route>
をリファクタリングする
-
- React Router を v6 にアップグレードする
とある。ガイドに従いながらやってみる。
元となるコード
とりあえず、簡単なアプリを作って、それで実行してみる。
$ yarn create react-app sample --template typescript
$ cd sample
$ ncu -u
$ yarn install
$ yarn add react-router-dom@5.1
$ yarn add --dev @types/react-router-dom
ncu
はnpm-check-updates
だ。これで、react-router-dom のバージョン以外はピカピカな typescript の React アプリができた。package.json は以下。
{
"name": "sample",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.4.0",
"@types/node": "^17.0.6",
"@types/react": "^17.0.38",
"@types/react-dom": "^17.0.11",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "6.2",
"react-scripts": "5.0.0",
"typescript": "^4.5.4",
"web-vitals": "^2.1.2"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/react-router-dom": "^5.3.2"
}
}
とりあえず、React v16.8 以上にアップグレードする、React Router を v5.1 にアップグレードするは完了していることになる。
ひとまず、App.tsx を書き換える
import { BrowserRouter } from "react-router-dom";
import { Router } from "./router/Router";
export default function App() {
return (
<BrowserRouter>
<Router />
</BrowserRouter>
);
}
こいつはリファクタリング対象外だが、一応。
Router は以下の3つを用意した。あるあるな構成かと思う。
import React, { VFC, memo } from "react";
import { Route, Switch } from "react-router-dom";
import { Home } from "../components/pages/Home";
...
import { MyRouter } from "./MyRouter";
import { PrivateRoute } from "./PrivateRouter";
export const Router: VFC = memo(() => {
return (
<Switch>
{/* 通常の最上位ルート */}
<Route exact path="/">
<Home />
</Route>
{/* 通常のルート */}
<Route path="/public">
<Public />
</Route>
{/* パスからパラメータを取るタイプ */}
<Route
path="/profile/:id"
render={({ match }) => <Profile id={match.params.id} />}
/>
{/* プライベートルート */}
<PrivateRoute exact={false} path="/secret" loginUser={true}>
<Secret />
</PrivateRoute>
{/* プライベートかつ設定を読み込むルート */}
<Route
path="/mypage"
render={({ match: { url } }) => (
<Switch>
{MyRouter.map((route) => (
<PrivateRoute
exact={route.exact}
path={`${url}${route.path}`}
loginUser={true}
>
{route.children}
</PrivateRoute>
))}
</Switch>
)}
/>
{/* 404 */}
<Route path="*">
<NotFound />
</Route>
</Switch>
);
});
import { memo, ReactNode, VFC } from "react";
import { Redirect, Route } from "react-router-dom";
//import { useLoginUser } from "../hooks/useLoginUser";
type Props = {
exact: boolean;
path: string;
children: ReactNode;
loginUser: boolean;
};
export const PrivateRoute: VFC<Props> = memo((props) => {
const { exact, path, children, loginUser } = props;
return loginUser ? (
<Route exact={exact} path={path}>
{children}
<div className="notice">
<i>you are in a private route.</i>
</div>
</Route>
) : (
<Redirect to="/" />
);
});
ログインしてたらページを表示、してなかったらトップページへリダイレクトというヤツ。
今回はただの検証なので、ログインしているかどうかはパラメータ(loginUser)で受け取ることにした。
import { MyPage } from "../components/pages/my/MyPage";
import { Bookmarks } from "../components/pages/my/Bookmarks";
import { Settings } from "../components/pages/my/Settings";
export const MyRouter = [
{
path: "/",
exact: true,
children: <MyPage />,
},
{
path: "/bookmarks",
exact: false,
children: <Bookmarks />,
},
{
path: "/settings",
exact: false,
children: <Settings />,
},
];
/mypage, /mypage/bookmarks, /mypage/settings のそれぞれの設定。
v5.1 へのアップグレード
path パラメータの取得方法変更
元々 useParams を利用していたが、解説にあったので動作確認のため、付けた。
- <Route
- path="/profile/:id"
- render={({ match }) => <Profile id={match.params.id} />}
- />
+ <Route path="/profile/:id" children={<Profile />} />
Profile コンポーネントでは、
...
type Props = { id: string };
export const Profile: VFC<Props> = memo((props) => {
const { id } = props;
return (
...
);
});
こうだったものを
...
import { useParams } from "react-router-dom";
type Props = { id: string };
export const Profile: VFC = memo(() => {
let { id } = useParams<Props>();
return (
...
);
});
こうした。
Switch 内の Redirect を削除
PrivateRouter 内に、Redirect があったので、書き換える。
- <Redirect to="/" />
+ <Route path={path} render={() => <Redirect to="/" />} />
ドキュメントでは、
まとめると、v4/5 から v5.1 へのアップグレードは、以下のようになります。
<Route render>
や<Route component>
props の代わりに<Route children>
を使用する
- 現在の Location やパラメータなど、ルーターの state にアクセスするには、hooks API を使用する
-
withRouter
をすべて hooks に置き換える -
<Switch>
内に存在しない<Route>
は、useRouteMatch
で置き換えるか<Switch>
で囲む
とあるが、render は例の中にもちょいちょい出てくるので、いったんこれで。
カスタム Route のリファクタリング
<Switch>
内の要素のうち、通常の<Route>
要素でないものを、通常の<Route>
に置き換えます。これには、<PrivateRoute>
スタイルのカスタムコンポーネントが含まれます。
ガイドが、さらっと流した!!!
今回の Route 内には、2箇所 PrivateRoute を使っているところがある。1つ目のシンプルな方から直してみる。
...
<PrivateRoute exact={false} path="/secret" loginUser={true}>
<Secret />
</PrivateRoute>
これを
...
<Route
path="/secret"
component={() => (
<PrivateRoute exact={false} path="/secret" loginUser={false}>
<Secret />
</PrivateRoute>
)}
/>
...
こう。結局、<PrivateRoute>
入ってるように見えるけど、<Route>
内の component として指定してある点が違う。これが後々どれほど役立つかと言うと、んー、まぁ?いったん、気にせず、複雑な方の PrivateRoute も同様に書き換える。
...
<Route
path="/mypage"
render={({ match: { url } }) => (
<Switch>
{MyRouter.map((route) => (
<Route
path={`${url}${route.path}`}
component={() => (
<PrivateRoute
exact={route.exact}
path={`${url}${route.path}`}
loginUser={true}
>
{route.children}
</PrivateRoute>
)}
/>
))}
</Switch>
)}
/>
...
この際、MyRouter.tsx で、この Route の root パスを一番下に持ってこないと、うまく動かない。
...
export const MyRouter = [
{
path: "/bookmarks",
exact: false,
children: <Bookmarks />,
},
{
path: "/settings",
exact: false,
children: <Settings />,
},
{
path: "/",
exact: true,
children: <MyPage />,
},
];
これでいったん今回の Router の v5.1 へのアップグレードは終わり。
使用したサンプルコードは、こちら(記事そのまんまではない)。
v6 へのアップグレード
まずは、react-router-dom v6 のインストール
$ ncu -u
$ yarn install
これにて、react-router-dom が 5.1 から 6.2 になった(本日現在)。
exact の削除
廃止されたそうなので、削除して回る。ただ削除するだけ。
component を element に変える
順番的には、Switch だが、先に、component を element に変更する。
...
{/* 通常の最上位ルート */}
<Route path="/" element={<Home />} />
{/* 通常のルート */}
<Route path="public" element={<Public />} />
{/* パスからパラメータを取るタイプ */}
<Route path="profile/:id" element={<Profile />} />
{/* プライベートルート */}
<Route
path="secret"
element={<PrivateRoute loginUser={false} children={<Secret />} />}
/>
{/* プライベートかつ設定を読み込むルート */}
// 後述
{/* 404 */}
<Route path="*" element={<NotFound />} />
...
注意点としては、path が絶対パスから相対パスに変更されている点だ。
だいぶスッキリしたが、PrivateRouter が element に対して component を返していると怒られるので、直す。ついでに、 Redirect を Navigate に置き換えた。
import { memo, ReactNode, VFC } from "react";
import { Navigate } from "react-router-dom";
//import { useLoginUser } from "../hooks/useLoginUser";
type Props = {
children: ReactNode;
loginUser: boolean;
};
export const PrivateRoute: VFC<Props> = memo((props) => {
const { children, loginUser } = props;
return loginUser ? (
<>
{children}
<div className="notice">
<i>you are in a private route.</i>
</div>
</>
) : (
<Navigate to="/" />
);
});
Switch を Routes に変更
一番外側の Switch は単純に Routes に変更すれば良い。問題は、ここ。
...
{/* プライベートかつ設定を読み込むルート */}
<Route
path="/mypage"
render={({ match: { url } }) => (
<Switch>
{MyRouter.map((route) => (
<Route
path={`${url}${route.path}`}
component={() => (
<PrivateRoute
exact={route.exact}
path={`${url}${route.path}`}
loginUser={true}
>
{route.children}
</PrivateRoute>
)}
/>
))}
</Switch>
)}
/>
...
なのだが、element 化したことと、相対パスになったことで?、こんなに複雑に書く必要がなくなり、Switch を利用する必要もなくなった。もっとシンプルに書ける。
...
<Route
path="mypage"
element={<PrivateRoute loginUser={true} children={<MyPage />} />}
>
{MyRouter.map((route) => (
<Route
path={route.path}
element={
<PrivateRoute loginUser={true} children={route.children} />
}
/>
))}
</Route>
...
これまで、mypage 以下は全て MyRouter.tsx にまとまっていたが、mypage の root パスが外に出た。
上記コードは、map を使わなければ、以下のような感じになっている。
<Route path="mypage" ...>
<Route path="bookmarks" ... />
<Route path="settings" ... />
</Route>
一番外側は、Routes じゃないの?と思うが、Route だ。root パスを外にだしたのと、path に絶対パスはくれるなと怒られるので、MyRouter.tsx を書き換える。
...
export const MyRouter = [
{
path: "bookmarks",
children: <Bookmarks />,
},
{
path: "settings",
children: <Settings />,
},
];
もう分けなくても良いかも。という気になってくる。
その他のリファクタリング
他にも色々あるが、今回は必要なかったので省略。ドキュメントにはコードのサンプルが豊富なので、よく読めば躓くこともないだろう。こういう場合どうなんの?というのがあれば、コメントしていただければ、暇な時に取り組みます。
絶対パスが相対パスになっていることと、正規表現でのマッチが変更になっている点あたりがポイントだろうか。
有効なパス
/groups
/groups/admin
/users/:id
/users/:id/messages
/files/*
/files/:id/*
無効なパス
/users/:id?
/tweets/:id(\d+)
/files/*/cat.jpg
/files-*
最終的なコード
import React, { VFC, memo } from "react";
import { Route, Routes } from "react-router-dom";
import { Home } from "../components/pages/Home";
import { Public } from "../components/pages/Public";
import { Secret } from "../components/pages/Secret";
import { MyPage } from "../components/pages/my/MyPage";
import { NotFound } from "../components/pages/NotFound";
import { MyRouter } from "./MyRouter";
import { PrivateRoute } from "./PrivateRouter";
export const Router: VFC = memo(() => {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="public" element={<Public />} />
<Route path="profile/:id" element={<Profile />} />
<Route
path="secret"
element={<PrivateRoute loginUser={false} children={<Secret />} />}
/>
<Route
path="mypage"
element={<PrivateRoute loginUser={true} children={<MyPage />} />}
>
{MyRouter.map((route) => (
<Route
path={route.path}
element={
<PrivateRoute loginUser={true} children={route.children} />
}
/>
))}
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
);
});
import { memo, ReactNode, VFC } from "react";
import { Navigate } from "react-router-dom";
//import { useLoginUser } from "../hooks/useLoginUser";
type Props = {
children: ReactNode;
loginUser: boolean;
};
export const PrivateRoute: VFC<Props> = memo((props) => {
const { children, loginUser } = props;
return loginUser ? (
<>
{children}
<div className="notice">
<i>you are in a private route.</i>
</div>
</>
) : (
<Navigate to="/" />
);
});
import { Bookmarks } from "../components/pages/my/Bookmarks";
import { Settings } from "../components/pages/my/Settings";
export const MyRouter = [
{
path: "bookmarks",
children: <Bookmarks />,
},
{
path: "settings",
children: <Settings />,
},
];
以上。終わり。あけましておめでとうございます。