こんにちは。フロントエンドチームのシニア・アーキテクトだ。
今日もみんな、コンポーネントのライフサイクルや副作用の制御に頭を悩ませていることだろう。
さて、今回は実務でReactを書いていて、誰もが一度は「おや?」と首を傾げるあの現象――「開発環境(StrictMode)での`useEffect`の二重実行」について、徹底的に解剖していく。
「APIリクエストがなぜか2回飛ぶんだけど!」
「タイマーやイベントリスナーの登録がおかしくなる!」
そんなバグ(あるいは仕様の誤解)に直面して冷や汗をかいた経験、君にもあるはずだ。
今回は、この「一見おかしなReactの挙動」がなぜ存在し、裏側でブラウザがどう動いているのか、そしてプロとしてどうコードを書きこなすべきか、現場のリアルな知見を交えて伝授しよう。
—
1. なぜStrictModeでは`useEffect`が2回走るのか?
まず大前提として知っておいてほしいのは、この二重実行はバグではなく、Reactチームが仕掛けた「未来への防衛策(仕様)」だということ。
プロダクションビルド(本番環境)では、この二重実行は発生しない。あくまでローカルの開発環境(`
Reactチームの意図は極めてシンプル。
「コンポーネントがマウントされて、即座にアンマウントされ、そしてもう一度マウントされる」というライフサイクルをあえて強制的に再現することで、開発者の君にこう問いかけているんだ。
> 「おい、お前の書いたそのコンポーネント、ちゃんと後始末(クリーンアップ)できてるか?」
現代のReact、特に将来導入されるConcurrent Features(Concurrent Modeなど)の世界では、画面の描画やUIツリーへの組み込み・取り外しは、もはや一度きりの固定されたイベントではない。ユーザーの操作や画面の優先度に応じて、Reactは裏側でコンポーネントを出し入れしたり、描画を中断・再開したりするようになる。
その荒波の中でもビクともしない、「何度マウント・アンマウントを繰り返されてもメモリリークせず、バグらない頑健なコード」を書けているか? それを強制的にテストするための警報装置が、StrictModeの二重実行なんだ。
—
2. 裏側で何が起きているのか?(ブラウザとReactの内部挙動)
タイムラインを頭の中で思い描いてみてほしい。
StrictModeが有効な開発環境で、コンポーネントが画面に現れたとき、Reactは以下のような意地悪な(しかし慈悲深い)シナリオを実行する。
1. マウント(1回目): コンポーネントがレンダリングされ、DOMにコミットされる。
2. エフェクト発火(1回目): `useEffect`の中身が実行される(API呼び出し、リスナー登録など)。
3. 即座のアンマウント: 「あ、やっぱ一度消すわ」と、Reactがシミュレーションとしてコンポーネントを一旦アンマウント扱いにする。
4. クリーンアップ発火: このタイミングで、1回目のエフェクトが返したクリーンアップ関数が実行される。ここでタイマーのクリアやイベントの解除ができていないと、すでに不要になった処理がメモリ上に残る(=メモリリークの温床)。
5. 再マウント(2回目): もう一度、コンポーネントがDOMにコミットされる。
6. エフェクト発火(2回目): 本番環境と同じ状態を作るため、もう一度`useEffect`の中身が実行される。
つまり、「正しくクリーンアップ関数を書く訓練」を、我々開発者に強制しているというわけだ。
—
3. 実務で使える! 綺麗で堅牢なコードパターン
口で言うだけなら誰でもできる。じゃあ、実務ではどう書くべきか。
「クリーンアップ関数を制する者は、Reactの副作用を制する」と言っても過言ではない。
よくあるアンチパターンと、それを美しく解決するベストプラクティスをコードで見ていこう。
パターンA:ウィンドウのリスナーを安全に購読する(王道のクリーンアップ)
ウィンドウのサイズ変更やスクロールイベントを監視する際の実装例だ。
クリーンアップ関数の中で、確実に`removeEventListener`を呼ぶのがプロの作法だ。
import { useState, useEffect } from ‘react’;
export const WindowSizeViewer = () => {
const [windowWidth, setWindowWidth] = useState
useEffect(() => {
// リスナーとして登録する関数を定義
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
// イベントリスナーの登録(副作用の発生)
window.addEventListener(‘resize’, handleResize);
console.log(‘リスナーを登録しました’);
// ★超重要:これがクリーンアップ関数!
// StrictModeの1回目の破棄や、本番環境でのアンマウント時に必ず呼ばれる
return () => {
window.removeEventListener(‘resize’, handleResize);
console.log(‘リスナーをクリーンアップしました’);
};
}, []); // 依存配列が空なので、マウント時とアンマウント時にのみ実行される
return (
現在のウィンドウ幅:
{windowWidth} px
);
};
【ここがポイント】
StrictMode下では、このコンポーネントを表示するとコンソールにこう出力される。
1. `リスナーを登録しました` (1回目)
2. `リスナーをクリーンアップしました` (シミュレーションによる破棄)
3. `リスナーを登録しました` (2回目・本番の挙動)
もしクリーンアップ関数を書き忘れていると、イベントリスナーが二重に登録されてしまい、パフォーマンス低下や予期せぬバグを引き起こす。StrictModeは、このミスを開発段階で露わにしてくれる最高の相棒というわけだ。
—
パターンB:APIリクエストの競合と「フラグ(AbortController)」の制御
「APIのフェッチが2回走るから、画面に二重でデータが表示される、またはエラーになる!」という相談を本当によく受ける。
データ取得系(Fetch APIなど)の副作用では、クリーンアップ関数内で「古いリクエストをキャンセルする」か「フラグを立てて破棄されたレスポンスを無視する」というアプローチが鉄則だ。
現代のモダンなフロントエンドであれば、`AbortController`を使うのが最もスマートだ。
import { useState, useEffect } from ‘react’;
type UserData = {
id: number;
name: string;
};
export const UserProfile = ({ userId }: { userId: number }) => {
const [user, setUser] = useState
const [loading, setLoading] = useState
const [error, setError] = useState
useEffect(() => {
// 1. ブラウザ標準の通信キャンセル用コントローラーを生成
const controller = new AbortController();
const { signal } = controller;
const fetchUserData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`https://api.example.com/users/${userId}`, {
signal, // fetchにシグナルを渡すことでキャンセル可能にする
});
if (!response.ok) {
throw new Error(‘データの取得に失敗しました’);
}
const data: UserData = await response.json();
setUser(data);
} catch (err: any) {
// AbortErrorはキャンセルされた時のエラーなので、意図的なものとして無視する
if (err.name !== ‘AbortError’) {
setError(err.message);
}
} finally {
setLoading(false);
}
};
fetchUserData();
// 2. クリーンアップ関数で通信を中断する
return () => {
controller.abort();
console.log(`User ${userId} のフェッチをキャンセルしました`);
};
}, [userId]);
if (loading) return
読み込み中…
;
if (error) return
エラー: {error}
;
if (!user) return null;
return (
{user.name}
);
};
このように、`AbortController`とクリーンアップを組み合わせることで、StrictModeの二重実行による無駄なネットワーク負荷を防ぎつつ、ユーザーが素早くページを行き来した際の競合(Race Condition)をも綺麗にいなすことができる。
—
シニアからのまとめ
ReactのStrictModeにおける二重実行は、最初は「めんどくさい仕様だな」と感じるかもしれない。実際、サードパーティ製のライブラリなどで内部のクリーンアップが雑なものを使っていると、開発中に警告が出てイライラすることもあるだろう。
しかし、この仕様の本質は「コンポーネントのライフサイクルをより堅牢に扱い、将来のReactへの移行や、予測不可能なUIの変動に耐えうるコードを書くためのトレーニング」に他ならない。
- 「副作用を仕掛けたら、必ずクリーンアップで後始末をする」
- 「API通信にはキャンセル機構を組み込む」
この2つを意識するだけで、君の書くReactコードの品質は一段も二段も跳ね上がる。
泥臭いバグに怯える前に、StrictModeという最高のテスト環境を味方につけて、洗練されたコンポーネントを組み上げていこうぜ。

コメント