React を開発モードかつ StrictMode で動かしていると、
「render が 2 回走る」「useEffect が 2 回実行される」という挙動に出会います。
最初はバグのように見えますが、これは React が意図的に行っている挙動です。
この記事では、
- なぜ render が 2 回走るのか
- なぜ useEffect まで 2 回走るのか
- React がそれによって何を検出しようとしているのか
を、内部構造の観点から整理します。
StrictModeとは何か
以下のようにStrictModeを使うと、開発モード限定で追加のチェックが有効になります。
// main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
StrictModeは「将来の React の動作(Concurrent React)」に耐えられるかを、
開発時にあぶり出すための安全装置です。
本番ビルドでは一切影響しません。
そもそも仮想DOMは?
Reactは、DOMを直接操作するのではなく、**DOMを表すJavaScriptオブジェクト(React Elementツリー)**をまず作ります。
stateが変わると、Reactは次のように動きます。
- state更新がスケジュールされる
- コンポーネント関数(例:
App())が再実行される - 新しいReact Elementツリーが生成される
この時点ではまだDOMには一切触れていません。
仮想DOMを使わない場合は、
「stateを変えたら、どのDOMをどう書き換えるか」
を開発者がすべて管理する必要がある。
Reactでは「stateをどう変えるか」だけを書けば、
「UIをどう変えるか」はReactが引き受ける、という設計になっています。
renderフェーズとcommitフェーズ
React の内部処理は、大きく 2 つのフェーズに分かれています。
renderフェーズ(計算)
↓
commitフェーズ(DOM更新)
renderフェーズとは
renderフェーズは純粋な計算のフェーズです。
ここでは以下のような処理が行われます。
- JSXの評価
- 条件分岐
- stateの読み取り
- React Element ツリーの生成
重要なのは、この段階ではDOMは一切触れられていないという点です。
そのためrenderフェーズでは、
- DOM 操作
- API 通信
- setTimeout / setInterval
- グローバル変数の変更
といった副作用を持つ処理をしてはいけません。
renderは「何度呼ばれても」「途中で捨てられても」問題ない必要があります。
commitフェーズとは
commitフェーズでは、renderフェーズの結果をもとに実際のDOMを更新します。
- DOMノードの作成・削除
- 属性変更
- ref更新
- useEffect実行
ここで初めて「外の世界」に影響が出ます。
commitフェーズは中断されず、必ず1回だけ実行されます。
commitフェーズが実行されないこともある
state を更新しても、結果が変わらなければ commit は行われません。
setState((x) => x); // 同じ値
この場合、
- render フェーズは実行される
- DOM に差分がないため commit は行われない
という挙動になります。
render が 2 回走る理由(StrictMode)
開発モード + StrictModeでは、Reactはrenderフェーズを意図的に2回実行(コンポーネント関数を2回呼び出し)します。
render(テスト)
render(テスト)
commit(1 回)
これはバグではなく、「renderフェーズに副作用が混ざっていないか?」を検出するためのテストです。
renderは「計算」であるべきなので、
- 何回実行しても同じ結果になるか
- 実行回数に依存していないか
- state → UIの対応が壊れていないか
をReactが確認しています。
ダメな例(render 中の副作用)
function App() {
fetch("/api/data"); // ❌ render中の副作用
return <h1>Hello</h1>;
}
StrictMode では、renderが2回走るため、APIリクエストも2回飛びます。
これは「将来Concurrent Renderingで壊れるコード」です。
正しい例
function App() {
return <h1>Hello</h1>;
}
useEffect(() => {
fetch("/api/data"); // ✅ commit後
}, []);
副作用はcommitフェーズ(useEffect)に寄せます。
useEffectが2回走る理由
StrictMode では、useEffect も 2 回実行されます。
正確には、次の流れが意図的にシミュレートされます。
mount
→ useEffect 実行
→ cleanup 実行
unmount
mount
→ useEffect 実行
これは「副作用が正しく後始末(cleanup)できているか」を検証するためです。
そもそも useEffect は何のため?
useEffect は DOM 更新後に、React の外の世界と同期するための場所
例:
- API 通信
- setInterval
- WebSocket
- localStorage
React が恐れている最悪のバグ
React が最も警戒しているのは、「副作用を登録したのに、解除し忘れること」です。
例えば次のコードは危険です。
useEffect(() => {
window.addEventListener("resize", onResize);
}, []);
cleanup がないため、
再マウント時にイベントが二重登録されます。
OKな例
useEffect(() => {
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
StrictModeのmount→cleanup→mountという流れでも安全に動作します。
StrictMode は「わざと壊しに来る」
- renderを何度も呼ぶ
- effectを即アンマウントする
- 開発者の想定を裏切る
という形で「このコードは本当に安全?」を強制的に試します。
まとめ
- renderが2回走るのはrenderが純粋関数であるかの検証
- useEffectが2回走るのはcleanupが正しくかけているかの検証
- どちらも開発モード限定
- 本番ビルドでは起きない
- StrictModeは「将来のReactに耐えられるコードか」を見るための仕組み
StrictModeで壊れるコードは、将来のReactでは本当に壊れる可能性が高いコードです。
「うるさい挙動」ではなく、React からの警告として受け取るのが正解です。