できない.dev

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 を外す代わりに、クリーンアップ関数で副作用を確実に取り消すのが正しい対処です。

よくある原因

  1. <React.StrictMode> で囲まれた開発ビルドを実行している(CRA / Vite / Next.js のテンプレートはデフォルトで有効)
  2. useEffect で fetch / WebSocket / setInterval を起動しながら戻り値のクリーンアップ関数を返していない
  3. 副作用が二重に走ることをバグだと誤認している(production ビルドでは 1 回のみ)
  4. StrictMode が暗黙に有効化されていることを把握しておらず、<StrictMode>main.tsx 内で探していない

解決策

1. Strict Mode は外さず、クリーンアップを返す

クリーンアップが書かれていれば、開発時に 2 回走っても結果は冪等になります(公式ドキュメント)。

useEffect(() => {
  const id = setInterval(() => console.log("tick"), 1000);
  return () => clearInterval(id);
}, []);

setIntervalclearInterval が対になっていれば、二重実行されてもタイマーは 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 buildnpm run preview 等で確認します。
StrictMode を外すと将来 React が同様の検査を再導入したときにバグを取りこぼします。

この記事は役立ちましたか?