【実務・中級編】 関数型更新による依存の削減 – React実践ガイド

こんにちは。チームのフロントエンド開発をリードしているシニアエンジニアだ。

日々の開発、本当にお疲れ様。Reactで複雑なUIやインタラクションを実装していると、必ずと言っていいほど直面する「壁」がある。そう、`useEffect` の依存配列(dependency array)のコントロールだ。

「ESLintに言われるがままに依存配列に状態(State)を追加したら、エフェクトが意図しないタイミングで何度も実行されて無限ループに陥った」
「無限ループを防ぐために `// eslint-disable-next-line react-hooks/exhaustive-deps` で黙らせた」

もし君のコードにそんな箇所があるなら、今すぐ手を止めよう。それは将来、原因不明のバグやパフォーマンス低下を引き起こす時限爆弾になる。

今回は、そんな依存配列の泥沼から抜け出すための最もエレガントで強力な武器、「関数型更新(Functional Updates)」について深く解説する。なぜ `setState(prev => …)` を使うだけで `useEffect` の依存関係を劇的に削減できるのか。Reactの裏側の仕組みから、現場で今すぐ使える実践コードまで、徹底的に解き明かしていこう。

—

1. なぜ依存配列は肥大化し、バグを誘発するのか?

そもそも、なぜ `useEffect` の依存配列にStateを記述すると問題が起きるのだろうか。

Reactの基本的なレンダリングモデルは「その瞬間のスナップショット」だ。Stateが更新されるたびにコンポーネント関数が再実行され、新しいPropsとStateに基づいた新しいDOMツリー(Virtual DOM)が計算される。

そして `useEffect` は、「依存配列に指定された値が、前回のレンダリング時と(`Object.is` による比較で)異なる場合のみ実行される」という仕様になっている。

ここで、ある「1秒ごとにカウントを1ずつ増やすタイマー」を考えてみよう。

// ⚠️ 典型的な「バグを孕んだ」または「非効率な」実装
useEffect(() => {
const interval = setInterval(() => {
// count に依存しているため、このエフェクトは毎回再実行される必要がある
setCount(count + 1);
}, 1000);

return () => clearInterval(interval);
}, [count]); // ← countを依存配列に入れざるを得ない

このコードは一見動く。しかし、裏側では非常に無駄なことが起きている。
1. `count` が `0` のとき、タイマーがセットされる。
2. 1秒後、`setCount(0 + 1)` が実行される。
3. `count` が `1` になり、コンポーネントが再レンダリングされる。
4. 依存配列の `count` が変わったため、クリーンアップ関数(`clearInterval`)が走り、タイマーが破棄される。
5. 再び新しいタイマーがセットされる。

本来、タイマー(`setInterval`)はコンポーネントのマウント時に「1度だけ」セットすればいいはずだ。それなのに、`count` の値が変わるたびに「タイマーの破棄と再生成」が繰り返されている。これはブラウザのタイマーリソースを無駄に消費するだけでなく、ミリ秒単位でのズレや、より複雑な非同期処理においては競合状態(Race Condition)を引き起こす温床になる。

—

2. 救世主 `setState(prev => …)` の仕組みとブラウザの裏側

この問題を一発で解決するのが、`useState` が提供する関数型更新(Functional Updates)だ。

// 🚀 関数型更新によるスマートな解決
useEffect(() => {
const interval = setInterval(() => {
// 現在の最新State(prev)を受け取って次のStateを返す関数を渡す
setCount(prev => prev + 1);
}, 1000);

return () => clearInterval(interval);
}, []); // ← 依存配列が「空」になった!

なぜこれで動くのか、そしてなぜ依存配列から `count` を完全に消し去ることができるのだろうか。Reactの内部挙動を紐解いてみよう。

Reactが「最新の状態」を保持する仕組み(クロージャの回避)

最初のダメな例で `count` を依存配列に入れなかった場合、いわゆる「ストールしたクロージャ(Stale Closure)」問題が発生する。`useEffect` のセットアップ関数が評価された時点の `count` の値(例えば `0`)が、`setInterval` のコールバック内に永久に閉じ込められてしまうため、何秒経っても `setCount(0 + 1)` が実行され続け、カウンターは `1` から進まなくなる。

しかし、`setCount(prev => prev + 1)` のように「関数」を渡すと、Reactの挙動はガラリと変わる。

Reactのファイバー(Fiber)アーキテクチャの内部では、Stateの更新要求は「アップデートキュー(Update Queue)」というリンクリストにスタックされる。
`setCount(prev => …)` を呼び出したとき、Reactは「現在のレンダリングスナップショットにおける `count` の値」を必要としない。単に「次のレンダリングフェーズで、キューにある最新のStateに対してこの関数を適用してね」という更新指示(アクション)だけを登録するのだ。

【通常の更新: setCount(count + 1)】
現在値「0」を読み取る ➔ 0 + 1 を計算 ➔ 「1」で更新要求 ➔ 依存配列に「0」がバインドされるため再実行が必要

【関数型更新: setCount(prev => prev + 1)】
「関数(prev => prev + 1)」をキューに登録 ➔ Reactがレンダリング直前に最新のStateをこの関数に注入して計算 ➔ 現在値を知る必要がないため、依存配列は空「[]」でOK!

これにより、`useEffect` はコンポーネントのマウント時に一度だけ実行され、タイマーも一度だけセットされ、コンポーネントがアンマウントされるまで一切破棄されないという、完璧なライフサイクルを手に入れることができる。ブラウザのメインスレッドに対しても、不要なGC(ガベージコレクション)やタイマーの再登録が発生しないため、非常に低負荷でクリーンな処理が実現する。

—

3. 現場で使える実践パターン

では、実際の現場でよく遭遇する「チャットアプリのメッセージバッファ」を例に、具体的なコードを見てみよう。

以下は、WebSocketなどからリアルタイムに送られてくるメッセージを配列に蓄積し、画面に表示するコンポーネントだ。依存配列の制御がいかにパフォーマンスに直結するか、コードをエディタに貼り付けて挙動をイメージしてみてほしい。

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

// モックのデータソース(実務でのWebSocketやEventSourceの代わり)
const messageService = {
subscribe(callback: (msg: string) => void) {
const interval = setInterval(() => {
callback(`メッセージ: ${new Date().toLocaleTimeString()}`);
}, 2000);
return () => clearInterval(interval);
}
};

export const MessageConsole: React.FC = () => {
const [messages, setMessages] = useState([]);
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected'>(‘disconnected’);

useEffect(() => {
// 接続ステータスを更新
setConnectionStatus(‘connected’);

// 購読(Subscription)を開始
const unsubscribe = messageService.subscribe((newMessage) => {
// 💡 BAD: setMessages([…messages, newMessage]) と書くと、
// 依存配列に `messages` を入れる必要があり、メッセージが届くたびに
// subscribe / unsubscribe が走り、コネクションが瞬きを繰り返す(実務で最悪のバグになる)。

// 👍 GOOD: 関数型更新を使用することで、messagesへの依存を完全に排除!
setMessages((prevMessages) => {
// メッセージの上限を100件に制限するようなロジックもここでスマートに書ける
const nextMessages = […prevMessages, newMessage];
return nextMessages.slice(-100);
});
});

// クリーンアップ関数
return () => {
unsubscribe();
setConnectionStatus(‘disconnected’);
console.log(‘クリーンアップが実行されました(コネクション切断)’);
};
}, []); // 👈 依存配列は完全に空。コンポーネントの寿命とエフェクトの寿命が完全に一致する。

return (

リアルタイムログ(ステータス: {connectionStatus})


{messages.length === 0 ? (

新しいメッセージを待っています…

) : (
messages.map((msg, index) => (

{msg}

))
)}

);
};

このコードの美しさは、`messages` 配列がどれだけ頻繁に更新されようとも、`messageService.subscribe` は最初の1回しか呼ばれない点にある。もし関数型更新を使わずに `messages` を依存配列に入れていたら、メッセージを受信するたびに「切断 ➔ 再接続」が走り、サーバーに甚大な負荷をかけるか、接続が壊れていただろう。

—

4. もう一歩先へ:複数の状態やPropsが絡む場合の処方箋

「先輩、`setState(prev => …)` は便利ですが、更新のロジックに他のStateやPropsの値が必要な場合はどうすればいいんですか?」

いい質問だ。実務ではそう単純にいかないことも多い。
例えば、「ユーザーが入力した『フィルター条件(State)』に基づいて、メッセージをフィルタリングしながら追加したい」という場合だ。

// ⚠️ フィルター条件(filterText)にも依存してしまう
setMessages(prev => {
if (newMessage.includes(filterText)) { // filterTextは外部のState
return […prev, newMessage];
}
return prev;
});

この場合、`filterText` を関数型更新の中で参照しているため、結局 `useEffect` の依存配列に `filterText` を入れざるを得なくなり、`filterText` が一文字変わるたびに購読が再セットアップされる。

これに対するアプローチはいくつかあるが、シニアとして君に授けたい処方箋は以下の2つだ。

処方箋A:データ取得と「表示ロジック(フィルタリング)」を分離する

もっともクリーンなのは、`useEffect` 内ではデータを「生」のままStateに保存し、レンダリングフェーズでフィルタリングを行う(または `useMemo` を使う)設計にすることだ。

// 1. エフェクトは生データをただ入れるだけ(依存配列は空)
useEffect(() => {
const unsubscribe = messageService.subscribe((msg) => {
setMessages(prev => […prev, msg]);
});
return () => unsubscribe();
}, []);

// 2. レンダリング時にフィルタリングする(不要なエフェクトの再実行を防ぐ)
const filteredMessages = useMemo(() => {
return messages.filter(msg => msg.includes(filterText));
}, [messages, filterText]);

処方箋B:`useReducer` を検討する

複数の状態が複雑に絡み合い、どうしてもエフェクト内でそれらを組み合わせた更新が必要な場合は、`useReducer` の導入を検討しよう。

`useReducer` の `dispatch` 関数は、Reactによって「同一性が保証されている(絶対に再生成されない)」ため、依存配列に入れる必要がない。状態遷移のロジックをコンポーネントの外(reducer)に切り出すことで、`useEffect` はただ `dispatch` を呼ぶだけになり、依存関係を極限まで減らすことができる。

—

5. まとめ:依存配列を減らすことは、設計を強固にすること

Reactの `useEffect` は、外部システム(DOM、ネットワーク、API、タイマーなど)とReactのStateを「同期(Sync)」するための仕組みだ。

依存配列を減らすテクニックは、単なる「パフォーマンス・チューニング」ではない。
エフェクトの「実行回数」を必要最小限に抑え、コンポーネントのライフサイクルと同期処理のライフサイクルを正しく設計するための、極めて本質的な設計行為なんだ。

今回のポイントをまとめよう。

1. Stateの次の値が「現在の値」だけに依存しているなら、迷わず `setState(prev => …)` を使おう。
2. これにより `useEffect` の依存配列からそのStateを排除でき、不要なクリーンアップと再実行を防げる。
3. 他のStateやPropsが絡む場合は、エフェクト内で無理に処理せず、「レンダリング時の計算(`useMemo`)」や「`useReducer`」への移行を検討しよう。
4. `eslint-disable-next-line` で依存配列を無視するのは最後の手段。基本的には「設計の敗北」を意味する。

次に君が書くコード、あるいは今抱えているプロジェクトのコンポーネントを見てみてほしい。不要な依存で `useEffect` が悲鳴を上げていないだろうか?
関数型更新という強力なツールを手に、ぜひスマートで堅牢なコードへリファクタリングしてみてほしい。何か分からないことがあれば、いつでもコードレビューで聞いてくれ。応援しているよ!

コメント

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