useEffectが2回実行されるのはなぜ?React 19のStrict Modeを完全理解する
Reactプロジェクトを新規作成して、useEffectで簡単にデータを取得しようとしたら... ネットワークリクエストが2回飛んでいる。コンソールのログも2回。何もかも2回。
「バグ?設定間違えた?Reactのバージョン下げた方がいい?」
大丈夫です。コードは壊れていません。これは仕様です。そして、この動作の理由を理解すると、より良いReact開発者になれます。さらに重要なのは、この動作でも正しく動くEffectを書けるようになれば、本番環境で起きるはずだったバグを開発中に防げるということです。
この記事では、何が起きているのか、Reactチームがなぜこの一見イライラする決定をしたのか、そして最も重要な、どんな状況でも動く堅牢なEffectの書き方を解説します。
何が起きているの?
こんなコードを書いたとしましょう:
import { useEffect, useState } from 'react'; function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { console.log('データ取得中...', userId); fetch(`/api/users/${userId}`) .then(res => res.json()) .then(data => setUser(data)); }, [userId]); return <div>{user?.name}</div>; }
開発モードでコンソールを見ると:
データ取得中... 123
データ取得中... 123
Networkタブにも同じAPIリクエストが2つ。おかしいですよね?
Reactを長く使っている方なら違和感があるかもしれません。React 18より前は、開発環境でもEffectは1回だけ実行されていました。何が変わったのでしょうか?
原因:React.StrictModeの二重呼び出し
原因はStrictModeです。普通はmain.jsxかindex.jsでこうなっているはず:
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import App from './App'; createRoot(document.getElementById('root')).render( <StrictMode> <App /> </StrictMode> );
React 18以降、StrictModeは意図的にこう動作します:
- コンポーネントをマウント
- すぐにアンマウント
- もう一度マウント
これは開発環境だけです。本番ビルドでは1回だけ実行されます。
ポイントは:この意図的な二重呼び出しは、コードのバグを見つけ出すために設計されているということです。
なぜわざわざそんなことを?理由を理解しよう
Reactチームが嫌がらせをしているわけではありません。Effectに関する重要な真実があるからです:
「2回実行して問題が出るなら、本番でもいずれ問題が出る。開発中に見つけられなかっただけ。」
実際にEffectが複数回実行される場面を考えてみましょう:
シナリオ1:開発中のFast Refresh
コードを書いてファイルを保存すると、Fast Refreshがコンポーネントを再レンダリングします。Effectがサブスクリプションを設定して正しくクリーンアップしなければ、重複したサブスクリプションが発生します。
シナリオ2:SuspenseとTransitions
React 18+のstartTransitionやSuspenseでは、Reactがレンダーを「サスペンド」してから再試行することがあります。コンポーネントがマウント、アンマウント、再マウントされるのは正常な並行レンダリングの動作です。
シナリオ3:実際のナビゲーションでの再マウント
ユーザーが別のページに移動して、戻るボタンを押すと、コンポーネントが再マウントされます。EffectがWebSocket接続を作ってクリーンアップしなければ、孤児接続が残ります。
シナリオ4:今後の機能
Reactチームは「Offscreen」API(表示される前にバックグラウンドでレンダリング)を準備中です。これらの機能は、通常のライフサイクルとしてコンポーネントが複数回マウント/アンマウントされることを必要とします。
**Strict Modeの二重呼び出しは、これらの実際の状況をシミュレートしています。**ここで問題がなければ、上記すべてのシナリオでも安全ということです。
本当の問題:cleanupのないEffect
多くの開発者が間違える箇所を見てみましょう:
useEffect(() => { const socket = new WebSocket('wss://api.example.com/realtime'); socket.onmessage = (event) => { setMessages(prev => [...prev, event.data]); }; }, []);
何が問題でしょうか? cleanup関数がありません。Strict Modeがアンマウントして再マウントすると、2番目のWebSocket接続が作成されます。最初の接続は孤児になり、メッセージを受信し続け、メモリを消費しますが、閉じる参照がありません。
正しいパターン:
useEffect(() => { const socket = new WebSocket('wss://api.example.com/realtime'); socket.onmessage = (event) => { setMessages(prev => [...prev, event.data]); }; // 👇 ここがポイント。アンマウント時に呼び出される return () => { socket.close(); }; }, []);
これでフローは:
- マウント → WebSocket #1を作成
- Strict Modeアンマウント → WebSocket #1を閉じる
- 再マウント → WebSocket #2を作成
一度に1つの接続だけ存在します。Effectは堅牢になりました。
よくあるEffectパターンの修正方法
よくあるEffectパターンと、Strict Modeでも問題ない書き方を見ていきましょう。
パターン1:AbortControllerを使用したデータ取得
問題のあるアプローチ:
// ❌ 問題あり:二重fetch、競合状態の可能性 useEffect(() => { fetch(`/api/users/${userId}`) .then(res => res.json()) .then(setUser); }, [userId]);
堅牢なアプローチ:
// ✅ 正解:cleanupで進行中のリクエストを中止 useEffect(() => { const controller = new AbortController(); fetch(`/api/users/${userId}`, { signal: controller.signal }) .then(res => res.json()) .then(setUser) .catch(err => { if (err.name !== 'AbortError') { console.error('Fetch失敗:', err); } }); return () => controller.abort(); }, [userId]);
これが解決すること:
- Strict Modeがアンマウントすると最初のリクエストがキャンセルされる
- 2番目のリクエストは正常に進行
- 高速ナビゲーションで古いリクエストが新しいデータを上書きしない(競合状態問題の解決)
パターン2:window/documentのイベントリスナー
// ❌ 問題あり:リスナーが溜まっていく useEffect(() => { window.addEventListener('resize', handleResize); }, []);
// ✅ 正解:きちんと削除 useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);
パターン3:タイマーとインターバル
// ❌ 問題あり:複数のインターバルが動く useEffect(() => { setInterval(() => { setCount(c => c + 1); }, 1000); }, []);
// ✅ 正解:clearする useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []);
パターン4:サードパーティライブラリの初期化
チャートライブラリやアニメーションフレームワークなどは初期化と破棄が必要:
// ❌ 問題あり:チャートが2回初期化される useEffect(() => { const chart = new ChartLibrary(containerRef.current, { data: chartData, }); }, [chartData]);
// ✅ 正解:cleanupでdestroyを呼ぶ useEffect(() => { const chart = new ChartLibrary(containerRef.current, { data: chartData, }); return () => chart.destroy(); }, [chartData]);
パターン5:アナリティクスとトラッキング
これは少し微妙です。アナリティクスは重複排除しない方がいいかもしれません:
// Strict Modeで2回発火する - 問題? useEffect(() => { analytics.track('page_viewed', { page: pathname }); }, [pathname]);
答え: アナリティクスサービスによります。ほとんどの現代的なアナリティクス(GA4、Amplitude、Mixpanelなど)は重複イベントをサーバーでフィルタリングまたはデバウンスします。開発中の2回発火は本番データに影響しません。
それでも重要な場合は、refでトラッキング済みかチェックできます:
const hasTracked = useRef(false); useEffect(() => { if (!hasTracked.current) { analytics.track('page_viewed', { page: pathname }); hasTracked.current = true; } }, [pathname]);
ただし注意:このパターンは本当のバグを隠す可能性があります。必要な時だけ使ってください。
アンチパターン:「一度だけ実行」フラグ
Strict Modeに初めて遭遇した開発者がこんな「解決策」を思いつきます:
// ⚠️ アンチパターン:やめてください useEffect(() => { let ignore = false; const run = async () => { const data = await fetchData(); if (!ignore) { setData(data); } }; run(); return () => { ignore = true }; }, []);
ちょっと待って—このパターンは非同期データ取得では正しいんです!ignoreフラグがアンマウント後のstate設定を防ぎます。でも誤用されることがあります:
// ❌ 間違い:Strict Modeの目的を無効化 const hasRun = useRef(false); useEffect(() => { if (hasRun.current) return; hasRun.current = true; // Effectロジック... }, []);
このパターンは「Strict Modeでも絶対に1回だけ実行」と言っています。なぜ問題かというと:
- バグを隠す。 cleanupが必要なのにない場合、このパターンは開発中に問題を隠します。
- 本番で壊れる。 コンポーネントが本当に再マウントされたら?Effectは実行されません。
- Reactの思想に反する。 Effectは複数回実行されても大丈夫なように書くべきです。
結論: 「一度だけ実行」パターンが必要と感じたら、自分に問いかけてください:なぜEffectが2回実行されると壊れるのか?答えはたいてい書き忘れたcleanup関数です。
React 19の新しいパターン:Server ActionsとuseフックReact 19はEffect問題を完全に回避する新しいパターンを導入しています。知っておくとデータ取得のアプローチを現代化できます。
データ取得用のuseフック
React 19はPromiseとContextを消費するuseフックを導入しました:
import { use, Suspense } from 'react'; function UserProfile({ userPromise }) { // `use`がpromiseをアンラップ const user = use(userPromise); return <div>{user.name}</div>; } // 親コンポーネント function App({ userId }) { const [userPromise] = useState(() => fetchUser(userId)); return ( <Suspense fallback={<div>読み込み中...</div>}> <UserProfile userPromise={userPromise} /> </Suspense> ); }
なぜ重要か: Promiseは親で1回作成されて渡されます。子コンポーネントはfetchのライフサイクルを管理しません—結果を読むだけです。Effect自体がないので「二重fetch」問題は完全になくなります。
ミューテーション用Server Actions
データ変更にはReact 19のServer Actionsが別のモデルを提供します:
// actions.js - サーバーで実行 'use server'; export async function createUser(formData) { const user = await db.users.create({ name: formData.get('name'), email: formData.get('email'), }); return user; }
// コンポーネント - useEffectなしで送信 import { createUser } from './actions'; function CreateUserForm() { return ( <form action={createUser}> <input name="name" /> <input name="email" /> <button type="submit">作成</button> </form> ); }
Server Actionsはフォームactionとして呼び出されるので、送信ハンドラやEffectが不要になります。アクションはサーバーで実行され、ReactがUI更新を処理します。
それでもuseEffectが必要な場合
新しいパターンは強力ですが、useEffectがなくなるわけではありません。まだ必要な場合:
- DOM測定(要素サイズの読み取り)
- ブラウザAPI購読(IntersectionObserver、ResizeObserver)
- サードパーティライブラリ連携
- state変化によるアニメーション
- WebSocket接続
- タイマー/インターバル管理
これらの場合、cleanup関数パターンは依然として重要です。
デバッグ戦略:Strict Modeのせい?本当のバグ?
二重実行を見たら、こう体系的にデバッグしてください:
ステップ1:Strict Modeが有効か確認
エントリーポイント(index.js、main.jsx、main.tsx)を確認:
<StrictMode> <App /> </StrictMode>
StrictModeがないのに二重実行されるなら、本物のバグです—おそらくルーティングか親コンポーネントのロジックの問題。
ステップ2:ログでフローを理解
useEffect(() => { console.log('Effect SETUP:', userId); return () => { console.log('Effect CLEANUP:', userId); }; }, [userId]);
Strict Modeではこう出力されるはず:
Effect SETUP: 123
Effect CLEANUP: 123
Effect SETUP: 123
マウント → アンマウント → 再マウントのサイクルが確認できます。
ステップ3:本番ビルドでテスト
本番ビルドをローカルで実行:
npm run build npm run preview
本番ではStrict Modeは動きません。ここでも二重実行されるなら本物のバグ。
ステップ4:依存配列を確認
予期せぬ再実行の一般的な原因は不安定な依存性:
// ❌ 毎レンダーで新しいオブジェクト作成 → 毎回Effect実行 useEffect(() => { // ... }, [{ some: 'object' }]); // ✅ 安定したプリミティブ useEffect(() => { // ... }, [userId]);
レンダー中にインラインで定義されたオブジェクト、配列、関数は毎回新しいものです。Effectが毎回再実行されます。
パフォーマンスへの影響:心配すべき?
自然な懸念:「二重実行でアプリが遅くならない?」
開発環境では: はい、わずかに。でも開発速度は元々本番とは違います。安全にバグを見つけるメリットの方がミリ秒より大きいです。
本番では: Strict Modeは完全に除去されます。パフォーマンス影響ゼロ。Effectはマウントごと/依存性変更ごとに正確に1回だけ実行されます。
Reactチームは常に言っています:**開発パフォーマンスを最適化しないでください。**開発ビルドには本番にはないチェック、警告、意図的なスローダウンがたくさんあります。
競合状態:中級シナリオ
ユーザーがプロファイルを高速で切り替えるとします:
// ユーザーが高速クリック:プロファイルA → B → C
適切なcleanupなしだとこうなりえます:
- リクエストA開始
- リクエストB開始
- リクエストC開始
- リクエストA完了 → stateがUser Aに
- リクエストC完了 → stateがUser Cに
- リクエストB完了 → stateがUser Bに ← 間違い!
ユーザーはUser Cを期待していますが、User Bが最後に完了しました。
AbortControllerがこれを解決します:ユーザーがプロファイルBをクリックするとリクエストAがキャンセル。プロファイルCをクリックするとリクエストBがキャンセル。リクエストCだけが完了します。
マウント時のみ実行すべきEffect
時には本当に「マウント時のみ」のロジックが必要なことがあります。refを慎重に使用:
const initialized = useRef(false); useEffect(() => { // 1回だけ行うべきセットアップを実行 initializeComplexLibrary(); if (!initialized.current) { initialized.current = true; // バックエンドへのアプリ登録など一度きりのセットアップ registerAppInstance(); } return () => { // ただしcleanupは常に行う cleanupComplexLibrary(); }; }, []);
注意:cleanupは毎回実行されます。refは「一度きり」の登録ロジックだけをガードします。
外部システムとの同期
Effectが React外のもの(DOM、外部API、ブラウザストレージ)とstateを同期する場合:
useEffect(() => { // マウント時に外部stateを読む const savedTheme = localStorage.getItem('theme'); if (savedTheme) setTheme(savedTheme); // 読むだけならcleanup不要 // ただしリスナーを設定するならクリーンアップする const handler = (e) => { if (e.key === 'theme') setTheme(e.newValue); }; window.addEventListener('storage', handler); return () => window.removeEventListener('storage', handler); }, []);
堅牢なEffectのためのベストプラクティス
これまで学んだことをまとめましょう:
1. 常にCleanup関数を返す
習慣にしてください。cleanupは不要と思っても:
useEffect(() => { // セットアップロジック return () => { // クリーンアップロジック(空コメントでも) // 何をクリーンアップすべきか考えさせられる }; }, [deps]);
2. すべてのFetchでAbortControllerを使用
このパターンは自然にできるべき:
useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then(/* ... */) .catch(err => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort(); }, [url]);
3. 変更可能な参照はRefに保存
cleanupサイクルを越えて保持されるべき変更可能なstateが必要なら:
const socketRef = useRef(null); useEffect(() => { socketRef.current = new WebSocket(url); return () => socketRef.current?.close(); }, [url]);
4. 再利用可能なパターンはカスタムフックに抽出
正しい動作を中央化:
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); setLoading(true); fetch(url, { signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(res.statusText); return res.json(); }) .then(setData) .catch(err => { if (err.name !== 'AbortError') setError(err); }) .finally(() => setLoading(false)); return () => controller.abort(); }, [url]); return { data, loading, error }; }
5. React QueryやSWRを検討
複雑なデータ取得にはこれらのライブラリがキャッシュ、重複排除、cleanupを自動処理:
// React Queryを使用 import { useQuery } from '@tanstack/react-query'; function UserProfile({ userId }) { const { data: user, isLoading, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()), }); if (isLoading) return <div>読み込み中...</div>; if (error) return <div>エラー発生</div>; return <div>{user.name}</div>; }
React Queryは自動でリクエストを重複排除します—Strict Modeの二重マウントでもネットワークリクエストは1つだけ。
Strict Modeを無効にすべき?
StrictModeを削除することは可能です:
// 前 <StrictMode> <App /> </StrictMode> // 後 <App />
すべきでしょうか? ほとんどの場合、いいえ:
- バグを直すのではなく隠しています。 そのバグは本番で現れます。
- 将来への備えを失います。 次のReact機能は堅牢なEffectに依存する可能性があります。
- コードスメルです。 Strict Modeを無効にする必要があるなら、Effectに構造的問題がある可能性が高いです。
Strict Modeを一時的に無効にする正当な理由は、デバッグ時に問題がStrict Mode由来か別のバグかを切り分けたい時だけです。
まとめ:二重実行を受け入れよう
React 19のStrict Modeはあなたの敵ではありません—より良いコードを書くためのトレーニングです。二重実行に遭遇するたびに、自問してください:
- Effectにcleanup関数があるか?
- cleanup関数は実際にEffectが設定したものをクリーンアップしているか?
- Effectが3回、10回、100回実行されても正しく動作するか?
3つすべてに「はい」と答えられるなら、Effectは本番準備完了です。
この記事のパターン—fetchにAbortController、購読にcleanup関数、変更可能なstateにref—これらはStrict Mode回避策ではありません。Effectを書く正しい方法です。Strict Modeはその重要性を無視できなくするだけです。
次にEffectが2回実行されたら、回避策を探さないでください。ユーザーが発見する前にバグを捕まえてくれたStrict Modeに感謝し、cleanup関数を書いてください。深夜2時に本番問題をデバッグする未来の自分が感謝するはずです。
最終更新:2025年12月