[React] SPA での同一 URL への遷移時にコンポーネントが再マウントされない場合の対処法
問題点
React で react-router-dom を使用して SPA を作成した場合、現在と同じ URL に遷移する際にコンポーネントが再マウントされません。
これにより以下のように期待と異なる動作をすることがあります。
- state が初期化されない。
- 第二引数に[]を指定した useEffect 内の副作用が呼び出されない。
これは以下のようにクエリパラメータの値が異なる同一 URL に遷移した場合も同じです。
- 遷移前:
http://localhost:3000/about?p=1
- 遷移後:
http://localhost:3000/about?p=2
原因
react-router-dom は URL とコンポーネントを紐づけているため、同一 URL に遷移する際にコンポーネントを再マウントしません。
これ自体は不具合ではなく仕様と思われますが、別 URL から遷移してきた時と同様に初期化処理(state の初期化や初回レンダリング時の処理など)を行なって欲しい場合には期待通りに動いてくれないことになります。
実例
例として以下のようなアプリを考えます。
import { Routes, Route, BrowserRouter } from "react-router-dom";
import About from "./components/About";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}
export default App;
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
const About = () => {
const navigate = useNavigate();
const [count, setCount] = useState<number>(0);
const transition = useCallback((path: string) => {
navigate(path);
}, []);
useEffect(() => {
console.log("use effect!");
}, []);
return (
<>
<div>Count: {count}</div>
<div>
<button
onClick={() => {
setCount((prev) => prev + 1);
}}
>
カウントアップ
</button>
</div>
<div>
<button onClick={() => transition("/about")}>同一URLに遷移</button>
</div>
</>
);
};
export default About;
About 画面で count を更新した後に同一 URL に遷移した際、count の初期化とコンソールへの"use effect!"の表示を期待しましたが、実際には About コンポーネントは再マウントされないためこれらは起こりません。
対処法
あるコンポーネントの key を一意に決めることで、例えばコンポーネントの並びが変わった際なども React は同じコンポーネントと認識し、DOM への再マウントを行いません。
逆に key を変更すれば異なるコンポーネントとして認識されるため、DOM への再マウントが行われ、今回の例で言う初期化処理が走ることになります。
key の値の管理に今回は Recoil を使用しますが、前回と違う key を割り当てられるなら他の方法でも問題ありません。
対処法 1. Route コンポーネントの element 属性に key を持たせ、画面遷移の際に key を変更する
import { Routes, Route, BrowserRouter } from "react-router-dom";
import About from "./components/About";
+ import { useRecoilValue } from "recoil";
+ import { transitionManager } from "./store/atoms/atoms";
function App() {
+ const transition = useRecoilValue(transitionManager);
return (
<BrowserRouter>
<Routes>
- <Route path="/about" element={<About />} />
+ <Route path="/about" element={<About key={transition.aboutKey} />} />
</Routes>
</BrowserRouter>
);
}
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+ import { useSetRecoilState } from "recoil";
+ import { transitionManager } from "../store/atoms/atoms";
const About = () => {
const navigate = useNavigate();
+ const setTransition = useSetRecoilState(transitionManager);
const [count, setCount] = useState<number>(0);
const transition = useCallback((path: string) => {
navigate(path);
+ setTransition((prev) => ({
+ ...prev,
+ aboutKey: Math.random(),
}));
}, []);
useEffect(() => {
console.log("use effect!");
}, []);
return (
<>
<div>Count: {count}</div>
<div>
<button
onClick={() => {
setCount((prev) => prev + 1);
}}
>
カウントアップ
</button>
</div>
<div>
<button onClick={() => transition("/about")}>同一URLに遷移</button>
</div>
</>
);
};
export default About;
(transitionManager の中身などは省略)
上記のようにソースコードを修正することで「同一 URL に遷移」ボタン押下時に About コンポーネントの持つ key がランダムに変更され、コンポーネントは再マウントされます。
対処法 2. Routes コンポーネントに key を持たせ、画面遷移の際に key を変更する
基本的には対処法 1 と同じことを、react-router-dom の Routes に対して行うだけです。
全体ソースコードは省略します。
- <Routes>
+ <Routes key={transition.routesKey}>
(About 関数内では同一 URL への遷移時に routesKey をランダムに変更します。)
なお Routes ではなく Route の方に key を設定し変更するパターンも試しましたが、再マウントは起こりませんでした。
結論
上記のようにコンポーネントの key を変更することで DOM への再マウントが行われ、state の初期化などを呼び出すことができます。
Discussion