はじめに
React 18以降の環境で開発していると、useEffect が2回走る現象によく遭遇しますよね。
今回、Ant Design の message(トースト)機能を使って実装していた際、この「2回実行」問題に直面しました。
最初は「どうせローカルの Strict Mode だけの話でしょ」とタカをくくっていたのですが、Staging環境でも再現してしまい、慌てて修正する羽目に…。
その過程で、「なぜStaging環境で発生したのか?」というビルド設定のミスと、「なぜReactはわざわざ2回実行するのか?」という根本理由を深掘りしたところ、「Reactからのメッセージ(教え)」 に気づくことができたので、自戒を込めて共有します。
1. 遭遇した現象:トーストが2回出る
ログインエラー時にトーストを表示するカスタムフックを作っていました。
// 修正前のコード
export const useAuthErrorMessage = (errorCode?: string) => {
const intl = useIntl();
useEffect(() => {
if (!errorCode) return;
// ❌ これだとトーストが2枚重なって表示されてしまう
message.error(intl.formatMessage(msg));
}, [errorCode, intl]);
};
開発環境(Local)だけでなく、Staging環境でもトーストが2回表示されてしまいました。
「1回しか呼ばれないだろう」という甘い期待(非べき等な実装)が、再レンダリングやビルド設定の差異によって露呈した形です。
2. なぜStaging環境でも発生したのか?
通常、この現象は React.StrictMode が有効な開発環境でのみ発生します。
なぜビルド後のStaging環境でも2回実行されたのか、原因はビルド設定とReactの判定ロジックにありました。
StrictMode はどこにいる?
まず、StrictMode は通常、アプリのエントリーポイント(index.tsx や main.tsx)で以下のように宣言されています。
// index.tsx の例
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const domNode = document.getElementById('root');
const root = createRoot(domNode!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Reactはどうやって本番/開発を見分けている?
Reactのソースコード内では、process.env.NODE_ENV という環境変数を見て挙動を変えています。
-
productionの場合: チェック機能を無効化し、高速化(2回実行もしない) - それ以外の場合: 開発モードとして動作(StrictMode有効)
今回のプロジェクトでは、vite build --mode staging でビルドした際、Viteの設定ファイル(vite.config.ts)経由で process.env.NODE_ENV に 'staging' という文字列を埋め込んでいました。
Reactにとっては 「'production' じゃないな? じゃあ開発モードだ!」 と判断され、Staging環境用ビルドにも関わらず Strict Mode が有効化したままになっていたのです。
設定ミスではありましたが、おかげで潜在的なバグに気づくことができました。
3. 解決策:Ant Designの key で「べき等性」を確保する
「2回実行させないようにする」のではなく、「何度実行されても大丈夫なようにする」のが正解です。
Ant Design の message コンポーネントには key プロパティがあります。
// ✅ 修正後のコード
useEffect(() => {
if (msg) {
// keyを指定することで、「新規作成」ではなく「更新」にする
message.error({
content: intl.formatMessage(msg),
key: errorCode, // エラーコードを一意のIDとして使う
});
}
}, [errorCode, intl]);
key を指定することで、2回目が実行されたときに Ant Design は「あ、同じIDのやつが既にあるな」と判断し、新規作成ではなく更新(Update) を行います。
これで、見た目上は1回しか表示されなくなりました。
4. 深掘り:なぜReactは2回走らせるのか?
そもそも、なぜ React.StrictMode は useEffect を2回走らせるのでしょうか?
調べてみると、そこには React の設計思想である 「純粋関数(Pure Function)」 が関わっていました。
コンポーネントは純粋であるべき
Reactにおいてコンポーネントは、「同じ入力(Props/State)なら、常に同じ出力(UI)を返す」 という純粋関数であることが前提とされています。
純粋関数であれば、計算を何度やり直しても(=レンダリングやEffectが何度走っても)、結果は同じで困らないはずです。
- 複数回走って結果が変わる → 純粋じゃない(副作用の管理が甘い)
- 純粋じゃない → バグの温床になる
つまり、Reactは開発環境でわざと2回走らせることで、「ほら、2回走ると壊れるコード(不具合の予備軍)がここにあるよ!」 と早期発見させてくれているのです。
5. 応用:その他のケース(FetchやTimer)
Antdの場合は key で解決しましたが、API通信などの場合はどうすればよいでしょうか?
これも「2回走っても大丈夫な形」にするパターンがあります。
ケースA:APIリクエスト (Fetch)
fetch が2回走ると、無駄な通信が発生したり、競合状態(Race Condition)になる可能性があります。
useRef を使って、「すでに実行済みか」を管理するガード節を入れるのが一般的な解決策の一つです。
import React, { useEffect, useRef, useState } from 'react';
function DataFetcher() {
const [data, setData] = useState(null);
// 実行済みフラグ
const hasFetched = useRef(false);
useEffect(() => {
// 既にfetch済みなら何もしない(ガード)
if (hasFetched.current) return;
hasFetched.current = true;
console.log("Fetching data...");
fetch('/api/data')
.then(res => res.json())
.then(result => setData(result));
}, []);
return <div>{data ? "Data loaded" : "Loading..."}</div>;
}
※ 最近は SWR や React Query などのライブラリを使うと、この辺りを自動でうまくハンドリングしてくれるのでオススメです。
ケースB:タイマーや購読 (Cleanup)
setInterval やイベントリスナー登録などは、2回走るとメモリリークの原因になります。
これは useEffect の クリーンアップ関数 を必ず返すことで解決します。
useEffect(() => {
const timer = setInterval(() => console.log("Tick"), 1000);
// クリーンアップ関数:アンマウント時(または再実行前)に呼ばれる
return () => clearInterval(timer);
}, []);
React 18のフロー(Mount -> Unmount -> Mount)において、最初の Unmount 時にクリーンアップが走るため、タイマーが重複することはありません。
まとめ
現象: トーストが2回出るのは、Reactが「コードの純粋性」をテストしてくれているから。
原因: ビルド設定で NODE_ENV が production 以外になっていたため、StagingでもStrict Modeが動いていた。
解決: Antdコンポーネントにkey プロパティを使って、何度呼んでも結果が変わらない(べき等な)実装にする。
教訓: Fetchなら useRef でガード、Timerなら cleanup 関数など、副作用の種類に応じた「2回実行対策」を知っておくと、予期せぬバグを防げる。
「なんで2回も動くんだよ!」とReactと喧嘩するのではなく、「堅牢なコードを書くための訓練」だと捉えると、付き合い方が変わってきますね。