【テクニカル・上級編】 React Strict Modeにおける二重実行 – React実践ガイド

こんにちは。フロントエンドの迷宮を日々彷徨い、ブラウザのメモリリークや非同期処理のゴーストと戦うあなたなら、一度はこう思ったことがあるはずだ。

「おい、開発環境(Strict Mode)で、なんで俺の `useEffect` がご丁寧に2回も走るんだよ……!」

コンソールを開けば同じログが双子のように並び、APIリクエストは無駄に2回飛び、カスタムフックの中のタイマーは暴走する。思わずキーボードを投げ出したくなるその挙動、実はReactチームからの最高にシニカルで、同時に最高に愛のある「テスト」なのだ。

今日は、このReact Strict Modeにおける副作用の二重実行という仕様の裏側を、ブラウザのライフサイクルとReactのアーキテクチャの観点から徹底的に剥ぎ取ってみよう。

—

なぜReactは「わざと」二回実行するのか?

結論から言えば、Strict Modeでの二重実行はバグではない。「コンポーネントがマウント・アンマウント・再マウントを繰り返すライフサイクルに対して、完全にレジリエント(回復力がある)であるか」を強制的に証明させるための検問だ。

React 18以降、私たちは「Concurrent React」という荒波の中に生きている。将来的にReactは、画面の裏側でコンポーネントのツリーを一時的に破棄し、別の場所で再利用したり、描画の優先順位付けのためにツリーの脱着を高速で繰り返したりするようになる(Offscreen APIや将来のプリレンダリング機能など)。

このパラダイムシフトにおいて、`useEffect` の中で構築した「世界の秩序」が、アンマウント時に綺麗にリセットされないとどうなるか?
メモリリーク、ゾンビ状態の非同期処理、そして二重に登録されたイベントリスナーによるUIのクラッシュ。これらはすべて、クリーンアップの怠慢が生んだ人災だ。

Reactは言っている。「本番環境でユーザーが泣きを見る前に、開発環境の段階でその甘えを叩き直しておけ」と。

—

現場で即死するアンチパターン:クリーンアップなき世界

まずは、多くのジュニア〜ミドルクラスのエンジニアがやりがちな、典型的かつ致命的なコードを見てほしい。

import { useState, useEffect } from ‘react’;

// 【アンチパターン】クリーンアップをサボった危険なコンポーネント
export function FlawedDataFetcher({ userId }: { userId: string }) {
const [data, setData] = useState(null);

useEffect(() => {
// データをフェッチするだけのつもりが……
let isCancelled = false;

fetchUserData(userId).then(result => {
// もしアンマウント後にこれが走ると、メモリリークや状態更新警告の原因になる
if (!isCancelled) {
setData(result);
}
});

// うわっ、クリーンアップ関数を書き忘れた!
// あるいは、外部のグローバルなイベントリスナーやタイマーを放置している
}, [userId]);

return

{data ? data.name : ‘Loading…’}

;
}

このコードをStrict Mode下で動かすと何が起きるか?
1. マウント時にフェッチ開始(1回目)
2. すぐにアンマウント(Strict Modeのシミュレーション)
3. 再マウント時にフェッチ開始(2回目)

結果としてネットワーク帯域が無駄に消費され、非同期処理のレスポンスが前後した場合には、古いデータで新しい状態が上書きされるという「競合状態(Race Condition)」の温床となる。

—

堅牢なアーキテクチャ:真にレジリエントな `useEffect` の書き方

では、プロのアーキテクトはどう書くべきか。
鍵となるのは、「副作用の開始と終了(クリーンアップ)を完全にシンメトリー(対称)にする」ことだ。

以下のコードは、競合状態を防ぎ、Strict Modeの二重実行を完璧にいなす実用的なパターンである。

import { useState, useEffect } from ‘react’;

// ダミーの非同期API関数
const fetchUserData = async (userId: string, signal: AbortSignal) => {
const response = await fetch(`/api/user/${userId}`, { signal });
return response.json();
};

export function RobustDataFetcher({ userId }: { userId: string }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
// 1. ブラウザ標準の AbortController を使ってネットワークリクエスト自体をキャンセルする
const controller = new AbortController();
const { signal } = controller;

let isStale = false; // 2重の安全弁としてフラグも用意する

const loadData = async () => {
setLoading(true);
setError(null);
try {
const result = await fetchUserData(userId, signal);

// アンマウント済み、あるいは古いリクエストであれば状態を更新しない
if (!isStale) {
setData(result);
}
} catch (err: any) {
// AbortError によるキャンセルはエラーとして扱わない
if (err.name !== ‘AbortError’ && !isStale) {
setError(err);
}
} finally {
if (!isStale) {
setLoading(false);
}
}
};

loadData();

// 【重要】クリーンアップ関数
// Strict Modeの1回目の実行直後、または真のアンマウント時に必ず呼ばれる
return () => {
isStale = true; // 状態更新をブロック
controller.abort(); // 実行中の通信を即座にabort(ブラウザのリソースを解放)
};
}, [userId]); // userIdが変わる度、あるいはマウント・アンマウント毎にこのサイクルが回る

if (loading) return

Loading…

;
if (error) return

Error: {error.message}

;

return

User: {data?.name}

;
}

この実装であれば、Strict Modeが裏でどれだけ意地悪くコンポーネントを脱着させようとも、無駄な通信は `abort()` によってキャンセルされ、メモリリークの芽は完全に断たれる。これが、プロダクション品質のコードだ。

—

高度な最適化:外部ストアの購読やWebSocketの制御

DOMのイベントリスナーやWebSocket、あるいはRxJSのような外部ストリームを扱う場合も原則は同じだ。
「登録したら、必ず解除する」。この鉄則を破る者は、やがて巨大化したSPAのメモリリークというモンスターに足元をすくわれる。

特にWebSocketの場合、Strict Modeの二重実行によって「接続→即切断→再接続」という挙動が瞬間的に発生する。もしバックエンドのサーバー側がこの高速なハンドシェイクを想定していないと、コネクション制限に引っかかったり、認証エラーのログが溢れ返ったりする。

これを防ぐためには、クリーンアップ関数内で確実にソケットの `.close()` を呼び出すことはもちろん、カスタムフックや `useRef` を使って接続状態のライフサイクルを厳密に管理する必要がある。

—

まとめ:Strict Modeを敵に回すな、味方に付けろ

「開発環境が重くなるから」「コンソールにログが2回出てウザいから」という理由で、`React.StrictMode` を外してしまう現場があるとしたら、それはエンジニアリングの敗北だ。

Strict Modeは、あなたのコードの脆弱性を事前にあぶり出してくれる「極めて優秀な静的・動的解析ツール」に他ならない。
`useEffect` を書くときは常に自問しよう。

「もし今、この瞬間にコンポーネントが強制消滅させられたとしても、私の残した副作用は綺麗に片付けられるか?」

この問いに胸を張って「Yes」と言えるようになった時、あなたの書くReactアプリケーションは、どんな高負荷な環境でも揺るぎない、真に堅牢なアーキテクチャを手に入れているはずだ。

コメント

タイトルとURLをコピーしました