【テクニカル・上級編】 Strict Modeにおける二重レンダリングとuseState – React実践ガイド

こんにちは。フロントエンドの現場で日々、不条理なバグや謎のパフォーマンス劣化と格闘しているエンジニアの皆さん。

ローカル環境でアプリを立ち上げた瞬間、コンソールに同じログが2回出力されるのを見て、「おいおい、俺の書いたコードのどこにバグがあるんだ」と冷や汗をかいた経験はないだろうか?
そう、犯人は君のコードではない。React 18の `StrictMode` だ。

今回は、この開発環境特有の「お節介」とも思える挙動、特に `useState` の初期化や状態更新、そして副作用(Effect)に与える影響について、ブラウザのレンダリングパイプラインとReactの内部アルゴリズムの深部から解き明かしていく。

—

1. なぜReactは二重レンダリングを実行するのか?

まず大前提として理解してほしいのは、この二重実行は本番環境(Production)では一切発生しないということだ。これはReactチームが、将来のConcurrent Features(並行機能)を見据えて仕込んだ、極めて意図的な「ストレステスト」に他ならない。

React 18以降、Strict Modeが有効な環境下では、コンポーネントの初期マウント時に以下のサイクルが強制される。

1. マウント(初回レンダリング)
2. アンマウント(クリーンアップの検証)
3. 再マウント(二回目のレンダリング)

なぜこんな面倒なことをするのか?
それは、「私たちのコンポーネントが、何度マウントされても破綻しない『純粋な関数(Pure Function)』として書かれているか」をあらかじめ暴くためだ。ブラウザのタブを切り替えたり、画面がサスペンドして再描画されたりする未来のReactにおいて、副作用が野放しになっているコードは致命的なメモリリークや競合を引き起こす。Strict Modeは、その地雷を開発段階で踏ませてくれる優秀な安全装置なのだ。

—

2. `useState` の初期化における罠

多くの開発者が陥る最初の罠が、`useState` の初期化処理(遅延初期化関数)における副作用だ。
例えば、重い計算やローカルストレージへのアクセス、あるいはユニークなIDの生成を初期値として渡しているケースを考えてみよう。

import React, { useState } from ‘react’;

// 🚨 やりがちなアンチパターン
const generateSessionId = () => {
console.log(‘ID生成処理が走りました’); // Strict Modeではこれが2回出力される
return Math.random().toString(36.substring(2));
};

export const UserSessionWidget = () => {
// 初期値に直接関数呼び出し、あるいは副作用を伴う関数を渡している
const [sessionId] = useState(generateSessionId());

return

現在のセッションID: {sessionId}

;
};

このコードをStrict Modeで動かすと、コンソールには `ID生成処理が走りました` が2回出力される。
「あれ? `useState` の初期化は最初の1回だけ走る仕様のはずじゃ……?」と混乱するかもしれない。

ここで重要なのは、Reactはコンポーネント関数自体を意図的に2回呼び出すという点だ。1回目の実行で生成された初期値は破棄され、2回目の実行結果が実際のStateとして採用される。そのため、`generateSessionId` の中でグローバルなカウンターをインクリメントしていたり、外部のミュータブルなストアを書き換えていたりすると、すでにこの段階でアプリケーションの状態が破損する。

対策:初期化関数は「純粋」でなければならない

遅延初期化(Lazy Initialization)のコールバックを使う場合でも、その内部は完全に純粋で、外部世界に副作用を及ぼさないものでなければならない。

// ✅ 堅牢な実装
export const SafeUserSessionWidget = () => {
// 関数を「実行して」渡すのではなく、「関数自体」を渡して遅延実行させる
const [sessionId] = useState(() => {
// このスコープ内の処理は、副作用がない純粋な計算に限定する
return Math.random().toString(36).substring(2);
});

return

現在のセッションID: {sessionId}

;
};

関数自体(`() => …`)を渡すことで、Reactは必要なタイミングで安全に初期化を1回(本番)または検証用に複数回評価することができる。

—

3. 非同期の競合と `useState` の更新バグ

次に、状態更新の非同期性とStrict Modeが組み合わさったときに発生する、より深刻なアーキテクチャ上の問題を見ていこう。

非同期処理(`setTimeout` や `fetch` など)の最中にコンポーネントが再マウントされたり、古いクロージャをキャプチャしたまま状態更新が行われたりすると、いわゆる「Stale State(古い状態)」の競合が発生する。

import React, { useState, useEffect } from ‘react’;

export const DataFetcher = () => {
const [count, setCount] = useState(0);

useEffect(() => {
// 意図的に遅延を入れた非同期処理
const timer = setTimeout(() => {
// 🚨 古い count の値を参照している危険なコード
setCount(count + 1);
}, 1000);

return () => clearTimeout(timer);
}, [count]); // countを依存配列に入れているため、毎秒レンダリングが走る泥沼

return

カウント: {count}

;
};

このコードはStrict Modeの二重マウントと組み合わさると、タイマーが多重に走ったり、予期せぬタイミングで状態がスキップされたりする。

対策:関数型アップデート(Functional Updates)の徹底

状態の更新が「直前の状態」に依存している場合、`setCount(count + 1` のように直接変数を参照してはならない。常に最新の状態を保証する関数型アップデートを使用するべきだ。

import React, { useState, useEffect } from ‘react’;

export const RobustDataFetcher = () => {
const [count, setCount] = useState(0);

useEffect(() => {
const timer = setTimeout(() => {
// ✅ 最新のStateを確実に受け取る関数型アップデート
setCount((prevCount) => prevCount + 1);
}, 1000);

// クリーンアップ関数を確実に実装し、メモリリークと競合を防ぐ
return () => clearTimeout(timer);
}, []); // 依存配列を空にし、エフェクトの乱発を防ぐ

return

堅牢なカウント: {count}

;
};

このアプローチにより、React内部のスケジューラがどのように状態更新をバッチ処理(Batching)しようとも、常に最新の正確なステートメントに基づいて安全に計算が行われる。

—

4. チーフアーキテクトからの提言:Strict Modeを敵視するな

現場のエンジニアから、「Strict Modeだとログが二重に出てウザいから無効化していいですか?」という質問をたまに受ける。私の答えは決まってこうだ。

「いや、そこを直せ。Strict Modeは君のコードの『ほころび』を事前に教えてくれる女神様だと思え」と。

Strict Modeにおける二重レンダリングで挙動がおかしくなるコンポーネントは、本番環境のユーザーの元でも、画面のちらつき、メモリリーク、予期せぬデータ不整合という形で必ず牙を剥く。

  • `useState` の初期化ロジックに副作用(APIコールや乱数生成、DOM操作など)が含まれていないか?
  • 非同期処理のレスポンスを処理する際、アンマウントされたコンポーネントに対して状態更新を行っていないか?
  • クリーンアップ関数が適切に記述され、サブスクリプションやタイマーが完全に破棄されているか?

これらを徹底的に洗い出し、厳格な(Strictな)コードベースを維持することこそが、スケールするReactアプリケーションを作り上げる唯一にして最大の近道なのだ。

さあ、エディタを開いて、君のコードベースのStrict Modeを今一度見直してみよう。コンソールに流れる無駄な二重ログを、完璧なアーキテクチャの美しさへと昇華させるのは、他の誰でもない、画面の前に座る君自身なのだから。

コメント

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