React で Strict Mode による useEffect の 2 重実行が解決できない
React 18 以降の Strict Mode は、開発ビルドのみコンポーネントを意図的に「マウント → アンマウント → 再マウント」させ、useEffect を 2 回走らせます。
クリーンアップ漏れを検出するための仕様で、本番では発生しません。
StrictMode を外さず、cleanup で副作用を取り消すのが正しい対処です。
#strict-mode#useEffect#react18#cleanup
公開:
要約
React 18 以降の Strict Mode は、開発ビルドでのみコンポーネントを意図的に 1 度アンマウント → 再マウントし、useEffect を 2 回走らせます。
これはクリーンアップ漏れによる副作用バグを早期検出するための仕様で、本番ビルドでは発生しません。
Strict Mode を外す代わりに、クリーンアップ関数で副作用を確実に取り消すのが正しい対処です。
よくある原因
<React.StrictMode>で囲まれた開発ビルドを実行している(CRA / Vite / Next.js のテンプレートはデフォルトで有効)useEffectで fetch / WebSocket / setInterval を起動しながら戻り値のクリーンアップ関数を返していない- 副作用が二重に走ることをバグだと誤認している(production ビルドでは 1 回のみ)
- StrictMode が暗黙に有効化されていることを把握しておらず、
<StrictMode>をmain.tsx内で探していない
解決策
1. Strict Mode は外さず、クリーンアップを返す
クリーンアップが書かれていれば、開発時に 2 回走っても結果は冪等になります(公式ドキュメント)。
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id);
}, []);setInterval と clearInterval が対になっていれば、二重実行されてもタイマーは 1 本に収束します。
2. fetch は AbortController で中断する
useEffect(() => {
const ctrl = new AbortController();
fetch("/api/data", { signal: ctrl.signal })
.then((r) => r.json())
.then(setData)
.catch((e) => {
if (e.name !== "AbortError") throw e;
});
return () => ctrl.abort();
}, []);開発時に最初のリクエストが中断され、再マウント後の 2 回目だけが state に反映されます。
本番ではそのまま 1 回のみ実行されます。
3. subscribe / addEventListener も対称に解除する
useEffect(() => {
const onResize = () => setSize(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);登録時と解除時で同じ関数参照を使う点に注意します。
4. 本番挙動を確認するときは production build を使う
開発サーバの再マウントを止めたいだけなら <StrictMode> を外す前に npm run build → npm run preview 等で確認します。
StrictMode を外すと将来 React が同様の検査を再導入したときにバグを取りこぼします。