12k
All articles

useEffectが2回実行される理由

React StrictModeで開発時にuseEffectが2回実行される理由と、適切なクリーンアップで通信やリスナーなどの問題を直す方法を解説します。

OpenReplay Team
OpenReplay Team
useEffectが2回実行される理由

開発環境では、React StrictModeが各エフェクトに対して「セットアップ → クリーンアップ → セットアップ」の順に処理を実行します。そのため、データ取得、購読(サブスクリプション)、ログ出力を行うエフェクトは、目に見える形で2回実行されます。

エフェクト内にfetchを1つだけ書いたのに、Networkタブには同じリクエストが2件並び、コンソールにもログが2回出力されます。自分のコードのバグか、React自体のバグのように見えるでしょう。

実際はどちらでもありません。この二重実行は、クリーンアップ処理を検証するための意図的なテストです。本記事では、StrictModeが何を行っているのか、このテストに失敗しがちな代表的な3種類のエフェクトの修正方法、問題を隠すだけの「修正」、そして修正が完了したかどうかの判断方法を解説します。フック自体のおさらいが必要な場合は、まずReactのuseEffectフックのガイドをご覧ください。

重要なポイント

  • StrictModeは、すべてのエフェクトに対してセットアップとクリーンアップのサイクルを1回余分に実行します。これは開発環境でのみ発生し、React 18とReact 19のどちらにも当てはまります。
  • StrictModeはオプトイン方式ですが、ViteのReactテンプレートはアプリを<StrictMode>でラップしており、Next.jsのApp Routerではデフォルトで有効になっています。
  • 正しいクリーンアップ関数は、セットアップで行った処理を過不足なく元に戻します。つまり、リクエストを中断し、リスナーを削除し、タイマーをクリアし、接続を閉じます。
  • 正しく修正した後でも、開発環境のNetworkタブにリクエストが2件表示されるのは想定どおりの挙動です。重要なのは、最新のレスポンスだけがstateを更新できるようにすることです。
  • StrictModeを削除したり、useRefで「実行済み」ガードを追加したりしても、クリーンアップの欠如が隠れるだけで修正にはなりません。

症状:fetchが2回実行される

クリーンアップのないデータ取得エフェクトは、この挙動に気づく最も一般的なきっかけです。次のコンポーネントは本番環境では問題なく動作しますが、開発環境では2回実行されます。

import { useState, useEffect } from 'react';

function ProductDetails({ id }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    console.log('setup');
    fetch(`/api/products/${id}`)
      .then((res) => res.json())
      .then((data) => setProduct(data));
  }, [id]);

  return <h2>{product?.name}</h2>;
}

setupのログが2回出力され、リクエストも2件送信されます。最初のリクエストをキャンセルする方法がReactに伝えられていないため、両方のリクエストが最後まで実行されます。

なぜ開発環境でuseEffectが2回実行されるのか

React StrictModeのリファレンスによると、StrictModeが有効な場合、Reactは開発中にすべてのエフェクトに対してセットアップとクリーンアップのパスを1回追加で実行します。エフェクトは「セットアップ → クリーンアップ → セットアップ」の順に実行され、コンポーネントがマウント直後にアンマウントされ、すぐに再マウントされたかのように振る舞います。

この二重実行が発生するのは開発ビルドのみです。本番環境では、エフェクトはコンポーネントのマウント時に1回実行され、その後は依存配列の値が変化したときか、コンポーネントが再マウントされたときにのみ再実行されます。

StrictModeはオプトイン方式で、<StrictMode>でラップされたコンポーネントにのみ適用されます。ただし、多くのプロジェクトではデフォルトでラップされています。ViteのReactテンプレートのmain.jsxは、<App />を<StrictMode>の内側でレンダリングしています。Next.jsのApp Routerでは、Next.js 13.5.1以降、StrictModeがデフォルトで有効になっています。Pages Routerのアプリでは、reactStrictMode: trueを設定してオプトインする必要があります。

このサイクルは、クリーンアップが実際に機能しているかどうかを確認するためのものです。「セットアップ → クリーンアップ → セットアップ」の実行で誤動作するエフェクトは、ユーザーがページから離れて戻ってきたときにも誤動作します。また、開発中にコードを編集してエフェクトが再実行されたときにも誤動作します。Next.jsのFast Refreshのドキュメントでは、エフェクトはときどき再実行されても問題なく動作すべきであると述べられており、StrictModeがこれを強制していることにも触れています。描画(ペイント)との関係でクリーンアップが正確にいつ実行されるのかを詳しく知りたい場合は、useEffectとuseLayoutEffectの比較をご覧ください。

2回実行されるuseEffectを修正するには

2回実行されるuseEffectを修正するには、セットアップで開始した処理を元に戻すクリーンアップ関数を用意します。よくある失敗パターンには、それぞれ具体的な修正方法があります。

開発環境で見られる症状実際の問題修正方法
fetchが2回実行され、古いデータが表示される可能性がある実行中のリクエストをキャンセルする処理がないクリーンアップでAbortControllerまたはignoreフラグを使う
リスナーや購読が2回発火する削除されていないクリーンアップで削除する
カウンターが2で終わるそのロジックが副作用ではないイベントハンドラーに移動する

fetchが2回実行される場合

useEffect内でfetchが2回実行される問題には、2つの修正方法があります。1つ目は、クリーンアップでリクエストを中断する方法です。fetchが中断されると、AbortError DOMExceptionでrejectされますが、これは安全に無視できます。

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/products/${id}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setProduct(data))
    .catch((err) => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [id]);

もう1つの方法はignoreフラグです。これはReactのエフェクトによる同期に関するガイドでデータ取得に使われているパターンです。

useEffect(() => {
  let ignore = false;
  fetch(`/api/products/${id}`)
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setProduct(data);
    });
  return () => {
    ignore = true;
  };
}, [id]);

どちらの方法で修正しても、開発環境ではリクエストが2件表示されます。AbortControllerの場合は最初のリクエストが中断され、ignoreの場合は両方のリクエストが完了しますが、stateを更新できるのは2件目だけです。本番環境では、リクエストは1件だけ送信されます。また、同じクリーンアップにより、idが変更された際に、遅れて届いたレスポンスが新しいレスポンスを上書きしてしまう問題も防げます。

リスナーがリークする場合

useEffect内で追加したイベントリスナーは、クリーンアップで削除しない限りリークします。ハンドラーをエフェクトの内部で宣言し、セットアップで追加したものと同じ関数参照をクリーンアップで削除できるようにします。

useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

カウンターが2回インクリメントされる場合

// Before: ends at 2 in development
useEffect(() => {
  setCount((c) => c + 1);
}, []);

開発環境ではセットアップが2回実行されるため、インクリメントも2回実行されます。カウンターを「デクリメントして元に戻す」ようなクリーンアップは書けません。これは、そもそもこのエフェクト自体が存在すべきではないことを示しています。インクリメントは、そのきっかけとなるイベントが発生する場所に配置しましょう。

<button onClick={() => setCount((c) => c + 1)}>Add</button>

StrictModeを削除する、またはuseRefでガードを追加すべきか

いいえ、どちらもすべきではありません。どちらの方法でも二重実行は見えなくなりますが、根本的なバグは残ったままです。

ラッパーを削除したり、Next.jsの設定でreactStrictMode: falseを指定したりしてStrictModeを無効にすると、テスト自体がなくなります。エフェクトにクリーンアップが欠けている状態は変わりません。

useRefによるガードは、つい使いたくなる方法です。

// Avoid: hides missing cleanup
const hasRun = useRef(false);

useEffect(() => {
  if (hasRun.current) return;
  hasRun.current = true;
  subscribe();
}, []);

このガードは開発環境で2回目のセットアップをスキップするため、コンソールはきれいに見えます。しかし、2回目の実行をスキップするuseRefフラグでは、クリーンアップの欠如は修正されません。開発環境で問題が隠れるだけで、実際に再マウントが発生するたびにリークが残ります。refは1つのコンポーネントインスタンスに属します。本番環境でルートがアンマウントされて再マウントされると、新しいインスタンスには新しいrefが割り当てられるため、エフェクトは再び実行されます。クリーンアップは依然として存在しないため、古い購読は削除されないまま残ります。

エフェクトのクリーンアップが欠けたアプリのセッションリプレイでは、このバグは、ブラウザの「戻る/進む」操作の後にリクエストが重複したり、古いデータが表示されたりする形で現れることがあります。これは、StrictModeが開発環境で表面化させているのと同じバグです。

エフェクトが不要な場合

2回実行されるエフェクトの多くは、本来エフェクトにすべきではなかったものです。エフェクトは、コンポーネントが画面に表示されていることを理由に、React外部の何かと同期するためのものです。You Might Not Need an Effect(そのエフェクトは不要かもしれない)では、不要なエフェクトの代表的な2つのグループを取り上げています。

イベント駆動の処理は、イベントハンドラーに記述すべきです。フォームの送信、「カートに追加」後のトースト表示、カウンターのインクリメントなどは、コンポーネントがレンダリングされたからではなく、ユーザーが何らかの操作をしたから発生するものです。

propsやstateから計算できる値は、レンダリング中に計算すべきです。

// Before: extra state, extra render, effect runs twice
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(first + ' ' + last);
}, [first, last]);

// After: computed during render
const fullName = first + ' ' + last;

簡単なチェック:エフェクトは正しく動作しているか

useEffectが正しく動作しているかを確認するには、セットアップとクリーンアップの両方でログを出力します。

useEffect(() => {
  console.log('setup');
  return () => console.log('cleanup');
}, []);

開発環境では、コンソールに次のように表示されるはずです。

setup
cleanup
setup

本番ビルドでは、setupが1回だけ出力されます。ログが「setup → cleanup → setup」の順に出力され、UIが最終的に正しい状態になっていれば、エフェクトは意図どおりに動作しており、修正すべき点はありません。

まとめ

開発環境でuseEffectが2回実行されるのは、StrictModeがクリーンアップの動作を検証しているためです。この挙動を抑え込むのではなく、2回実行される各エフェクトを見直し、このチェックをパスするように修正しましょう。具体的には、古いリクエストを中断または無視し、追加したものは削除し、イベント駆動のロジックや派生値はエフェクトの外に完全に移動します。エフェクトのクリーンアップが正しく機能するようになったら、Reactのエラーバウンダリを追加し、レンダリング中に例外をスローしたコンポーネントがページ全体を停止させないようにしましょう。

よくある質問

StrictModeが無効な本番環境でuseEffectが2回実行されるのはなぜですか?

本番環境では、依存配列のいずれかの値が変化したとき、またはコンポーネントが再マウントされたときにエフェクトが再実行されます。依存配列の値が前回のレンダリングと1つでも異なればエフェクトは再実行されるため、レンダリング中に生成されるオブジェクトや関数は毎回新しい値として扱われます。また、keyプロップの変更や、コンポーネントを削除して再追加する条件付きレンダリングによってもコンポーネントは再マウントされ、セットアップが再度実行されます。

開発環境でアナリティクスイベントが2回送信されるのを防ぐべきですか?

いいえ。Reactのドキュメントでは、ページ訪問のアナリティクス呼び出しはエフェクト内にそのまま残しておくことが推奨されています。ユーザーには1回実行されたか2回実行されたかは区別できず、本番ビルドでは各訪問につき1回だけイベントが送信されます。そもそも、開発マシンから本番環境のメトリクスにイベントを送信すべきではありません。イベントをデバッグする必要がある場合は、本番モードで動作するステージングビルドでテストするか、一時的にStrictModeを無効にしてください。

useLayoutEffectもStrictModeで2回実行されますか?

はい。useLayoutEffectのReactリファレンスには、useEffectと同じ開発環境での挙動が記載されています。StrictModeが有効な場合、Reactはまずセットアップとクリーンアップのパスを1回実行し、その後に実際のセットアップを実行します。レイアウトエフェクトにも、オブザーバーの切断やサードパーティ製ウィジェットのインスタンスの破棄など、対応するクリーンアップが必要です。useLayoutEffectに切り替えても変わるのは描画(ペイント)に対するエフェクトの実行タイミングだけであり、開発環境での実行回数は変わりません。

1つのコンポーネントだけStrictModeを無効にし、アプリの他の部分では有効のままにできますか?

いいえ。ツリーがStrictModeでラップされると、その内部のすべてのコンポーネントがチェックの対象となり、個別のコンポーネントだけをオプトアウトすることはできません。代わりに、StrictModeのラッパーをツリーの下位に移動し、アプリの一部だけを対象にすることは可能です。StrictModeがルートをラップしていない場合、Reactは初回マウント時の追加のエフェクト実行を省略します。この追加実行が行われると、親のエフェクトは1回しか実行されないのに子のエフェクトだけが2回実行されることになり、本番環境ではあり得ない状況になってしまうためです。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.