—
依存配列との不毛な戦いに終止符を:関数型更新(Functional Updates)が救うReactのレンダリング・アーキテクチャ
Reactで中大規模のフロントエンド・アプリケーションを開発していると、誰もが一度は `react-hooks/exhaustive-deps` というESLintルールとの「不毛な戦い」に直面します。
「警告が出たから、おとなしく依存配列(deps)に状態を入れた。そうしたら、今度はエフェクトが意図しないタイミングで何度も再実行され、無限ループや不要なAPIリクエスト、タイマーの再設定が発生してしまった。仕方がないから `// eslint-disable-next-line` で黙らせた……」
もしあなたのコードベースにこのコメントアウトが散見されるなら、それはReactの「同期(Synchronization)」のメンタルモデルと、コンポーネントのライフサイクルが衝突している危険なサインです。
本稿では、このジレンマを根本から解決する極めてシンプルかつ強力なアプローチ、「関数型更新(Functional Updates)」を用いた依存の削減について、Reactの内部挙動(Fiberアーキテクチャや更新キュー)を踏まえて深く掘り下げます。単なる書き方のテクニックに留まらず、なぜこれがメモリ効率やレンダリング負荷、そして非同期の競合バグを回避するための「銀の弾丸」になり得るのかを解説します。
—
1. 依存配列に「状態」を載せることの代償
まずは、私たちが日常的に遭遇する、一見何の問題もなさそうな実装を見てみましょう。
以下は、WebSocketから送られてくるログデータを配列に蓄積し、画面に表示するコンポーネントのプロトタイプです。
// ⚠️ 避けるべき実装:依存配列が原因で予期せぬ再接続が発生する例
import React, { useState, useEffect } from ‘react’;
export const LogViewer: React.FC = () => {
const [logs, setLogs] = useState
useEffect(() => {
// 擬似的なWebSocketクライアントの確立
const socket = new WebSocket(‘wss://example.com/logs’);
socket.onmessage = (event) => {
// ログ配列の後ろに新しいログを追加したい
// 依存配列に `logs` が含まれているため、このクロージャは最新の `logs` を参照できるが…
setLogs([…logs, event.data]);
};
return () => {
// クリーンアップ処理
socket.close();
};
}, [logs]); // 🔴 痛恨の極み:logsが変わるたびに接続・切断が繰り返される
return (
))}
);
};
何が起きているのか?
一見すると、このコードは正常に動作するように見えます。しかし、ブラウザのネットワークタブを開くと、ログが1行追加されるたびに、WebSocketの切断(`close`)と再接続が狂ったように繰り返されていることに気づくはずです。
原因は明白です。
1. 新しいログが届く。
2. `setLogs([…logs, event.data])` が実行され、`logs` の参照が変わる(状態更新)。
3. `logs` が更新されたため、Reactは `useEffect` のクリーンアップ関数(`socket.close()`)を実行する。
4. 再び `useEffect` のセットアップが走り、新しいWebSocket接続が確立される。
これはパフォーマンス的に最悪であるばかりか、サーバー側にも甚大な負荷を与えます。
かといって、ESLintの警告を無視して `[logs]` を `[]`(空の配列)に変更すると、今度は「Stale Closure(古いクロージャ)」の問題が発生します。エフェクトがマウント時の `logs`(空配列 `[]`)を握りつぶしてしまい、何度ログが届いても `setLogs([…[], event.data])` が実行され、画面には常に「最後の1件」しか表示されないという致命的なバグに化けます。
—
2. 救世主としての「関数型更新」とその内部メカニズム
この「最新の状態を参照したいが、その状態の変化でエフェクトを再実行したくない」というジレンマを、エレガントに解決するのが関数型更新(Functional Updates)です。
// ✨ 劇的改善:関数型更新を用いて依存を完全に排除したコード
import React, { useState, useEffect } from ‘react’;
export const OptimizedLogViewer: React.FC = () => {
const [logs, setLogs] = useState
useEffect(() => {
const socket = new WebSocket(‘wss://example.com/logs’);
socket.onmessage = (event) => {
// 💡 関数型更新を使用:現在の最新状態(prev)を引数で受け取る
setLogs((prevLogs) => […prevLogs, event.data]);
};
return () => {
socket.close();
};
}, []); // 🟢 完璧:依存配列は「空」。WebSocketの接続はマウント時の1度きり
return (
))}
);
};
なぜこれで動くのか?:React内部の「更新キュー」
Reactの `useState`(およびその内部の `useReducer`)は、単に「新しい値をメモリに書き込む」だけの仕組みではありません。
`setLogs((prevLogs) => …)` のように関数を渡したとき、Reactはその関数を即座に評価して状態を書き換えるのではなく、対象のFiber(Reactの仮想DOMノード)が持つ 「Update Queue(更新キュー)」 にその関数をタスクとして登録します。
[ユーザーの操作 / イベント発生]
│
▼
setLogs(prev => […prev, data]) ─► ReactのUpdate Queueに「関数」がプッシュされる
│
┌───────────────────────────────────────┘
▼
[次のレンダリングフェーズ]
Reactがキューを順番に処理。
「その時点での最新の状態(prev)」を関数に流し込み、次の状態(nextState)を算出する。
このアーキテクチャのおかげで、`useEffect` が宣言されたタイミングのスコープ(クロージャ)に存在する `logs` の値を参照する必要がなくなります。エフェクト側はただ「こういう風に更新してね」という更新の意思(レシピ)をReactに放り投げるだけでよくなり、依存配列から `logs` を完全に追放できるのです。
—
3. アーキテクチャの観点から見るメリット
このテクニックは、単に「警告が消えてスッキリした」というレベルの話ではありません。アプリケーションの堅牢性を決定づける、極めて重要なアーキテクチャ上のメリットをもたらします。
1. メモリ効率とGC(ガベージコレクション)の最適化
Reactにおいて、`useEffect` の再実行は単に「関数がもう一度走る」だけではありません。
古いエフェクト内で作成されたオブジェクト、イベントリスナー、タイマーIDなどが破棄され、新しいインスタンスがメモリ上に確保されます。関数型更新によってエフェクトの再実行を最小限に抑えることは、V8などのJavaScriptエンジンにおけるマイナーGCの発生頻度を劇的に下げ、ガベージコレクションによるメインスレッドの瞬発的なブロッキング(カクつき)を防ぐことに直結します。
2. 非同期処理の競合(Race Conditions)の自然な回避
Webアプリケーションでよくある「ボタンを連打すると、古いリクエストの結果が新しい結果を上書きしてしまう」といった競合状態。
値を直接代入する `setState(newValue)` では、非同期処理が完了した時点の古いコンポーネントスコープの値を使って計算を行ってしまいがちです。関数型更新を徹底することで、状態の更新は常に「適用される瞬間の最新状態」をベースにアトミック(不可分)に行われるため、不整合なデータ状態に陥るリスクを構造的に排除できます。
—
4. 応用:さらに複雑な状態遷移における `useReducer` への昇華
「関数型更新が強力なのは分かった。でも、更新するのに `logs` だけでなく、同時に `isPaused`(一時停止フラグ)や `filterKeyword` といった『複数の状態』を参照しなければならない場合はどうするのか?」
非常に鋭い指摘です。
例えば、「一時停止フラグ(`isPaused`)が `false` のときだけ、ログを配列に追加したい」という仕様を追加してみましょう。
// ⚠️ 限界:複数の状態が絡むと、関数型更新だけでは依存配列が膨らんでしまう
const [logs, setLogs] = useState
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
const socket = new WebSocket(‘wss://example.com/logs’);
socket.onmessage = (event) => {
// 依存配列に `isPaused` を入れざるを得ない
if (!isPaused) {
setLogs((prev) => […prev, event.data]);
}
};
return () => socket.close();
}, [isPaused]); // 🔴 isPausedが切り替わるたびにWebSocketが再接続される!
このような「複数の状態が複雑に絡み合う更新ロジック」に直面したときこそ、Reactが提供するもう一つの強力な武器、`useReducer` の出番です。
`useReducer` の `dispatch` 関数は、「アイデンティティが完全に固定されている(絶対に参照が変わらない)」という保証があります。これを利用して、状態の「読み取り」と「書き込み(アクションの通知)」を完全に分離します。
// ✨ 究極のアーキテクチャ:useReducerによる状態と副作用の完全分離
import React, { useEffect, useReducer } from ‘react’;
// 1. 状態の型定義
interface State {
logs: string[];
isPaused: boolean;
}
// 2. アクションの型定義
type Action =
| { type: ‘ADD_LOG’; payload: string }
| { type: ‘TOGGLE_PAUSE’ };
// 3. レデューサー関数(純粋関数であり、Reactの外側でもテスト可能)
const logReducer = (state: State, action: Action): State => {
switch (action.type) {
case ‘ADD_LOG’:
if (state.isPaused) return state; // 一時停止中はログを追加しない
return {
…state,
logs: […state.logs, action.payload],
};
case ‘TOGGLE_PAUSE’:
return {
…state,
isPaused: !state.isPaused,
};
default:
return state;
}
};
export const AdvancedLogViewer: React.FC = () => {
// 状態管理をreducerに委譲
const [state, dispatch] = useReducer(logReducer, { logs: [], isPaused: false });
useEffect(() => {
const socket = new WebSocket(‘wss://example.com/logs’);
socket.onmessage = (event) => {
// 💡 dispatchは絶対に不変なので、依存配列に入れる必要がない(入れても安全)
dispatch({ type: ‘ADD_LOG’, payload: event.data });
};
return () => {
socket.close();
};
}, []); // 🟢 完全に空の依存配列。WebSocketの寿命はコンポーネントと一蓮托生。
return (
))}
);
};
このアプローチがもたらすブレイクスルー
`useReducer` を導入することで、エフェクト内部からは「現在の状態がどうなっているか(`isPaused` や `logs` の中身)」が完全に隠蔽されました。
エフェクトの役割は、単に「外部の世界(WebSocket)でイベントが発生したから、それをReactの世界に `dispatch` で伝える」という、純粋なイベントのブリッジ(架け橋)に純化されます。
これにより、副作用のライフサイクル(接続・切断のタイミング)は、コンポーネントの状態変化から完全にデカップリング(疎結合化)され、堅牢極まりないフロントエンド・アーキテクチャが完成します。
—
5. まとめ:依存配列をコントロールする者が、Reactをコントロールする
Reactの `useEffect` は、「マウント・アンマウント時に処理を行うためのライフサイクルフック」ではありません。「コンポーネントの現在のプロップス・状態と、外部システムを『同期』させるための仕組み」です。
しかし、その同期の過程において、単に「最新の値が欲しいから」という理由だけで依存配列に状態を無自覚に詰め込むと、アプリケーションのパフォーマンスと安定性は瞬く間に崩壊します。
- 状態の単純な更新には、`setState(prev => …)` による関数型更新を徹底し、不要な依存を徹底的に排除する。
- 複数の状態が絡む複雑なロジックには、`useReducer` を適用し、エフェクトから状態そのものを隠蔽する。
この2つの鉄則を胸に刻むだけで、あなたの書くReactコードからは「謎の再実行」や「無限レンダリング」といった怪奇現象が一切消え去るはずです。
フレームワークの内部挙動を味方につけ、より美しく、より堅牢なWebアプリケーションを構築していきましょう。

コメント