状態管理の亡霊:なぜあなたの `useState` は連続更新で裏切るのか
こんにちは。日々、Reactのファイバーツリーの深淵を覗き込み、ブラウザのメインスレッドと和解するためのコードを書き殴っているフロントエンド・アーキテクトだ。
プロダクトが成長し、ユーザーインタラクションが複雑化するにつれて、不可解なバグに直面したことはないだろうか?「ボタンを素早く2回連打したのに、カウントが1しか増えない」「非同期処理のレスポンスが複数重なった結果、最新の状態が古いスナップショットで上書きされて消滅した」――。
犯人は、あなたが何気なく使っている `useState` の通常更新(値の直接渡し)だ。そして、その救世主となるのが「関数型更新(Functional Update)」である。
今回は、Reactの非同期レンダリングの裏側、メモリとクロージャの闇、そして関数型更新がなぜ競合状態(Race Condition)の特効薬になるのかを、実務レベルのコードと共に徹底的に解剖していこう。
—
1. なぜ `useState` は裏切るのか:Reactの非同期バッチングとクロージャ
まずは敵を知ることから始めよう。以下のコードを見てほしい。一見、何の問題もないように見えるだろう。
import React, { useState } from ‘react’;
export function Counter() {
const [count, setCount] = useState(0);
const handleRapidClick = () => {
// 3回連続で呼んでいるから、3増えるはず……?
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return (
Count: {count}
);
}
このボタンをポチッと押したとき、画面上の `count` はいくつになると思う?
答えは 「1」 だ。3ではない。君の期待は見事に裏切られる。
クロージャが捉えた「古いスナップショット」
JavaScriptの関数コンポーネントが実行されるとき、その中で定義された変数や関数は、そのレンダー時のスコープ(クロージャ)に閉じ込められる。
`handleRapidClick` が実行された瞬間、スコープ内にある `count` の値は `0` だ。
つまり、React内部で何が起きているかというと、以下のようなコードを実行しているのと同じことだ。
// React内部の脳内イメージ
setCount(0 + 1); // setCount(1)
setCount(0 + 1); // setCount(1)
setCount(0 + 1); // setCount(1)
React 18以降、パフォーマンス最適化のために複数の状態更新は自動バッチング(一括処理)され、1回のレンダリングサイクルにまとめられる。しかし、バッチングされる前の「渡された値」自体がすべて `1`(`0 + 1`)であれば、Reactがどれだけ優秀でも、最終的な結果は `1` にならざるを得ない。これが、非同期バッチングとクロージャが生む最初の罠だ。
—
2. 関数型更新(Functional Update)という名の絶対防衛ライン
この競合状態を粉砕するのが、`setCount` に「値を直接渡す」のではなく、「次の状態を計算するコールバック関数(アップデーター関数)を渡す」 という手法だ。
Reactのコアは、アップデーター関数を受け取ると、その時点での最新の状態(最新のキューの評価値)を自動的にその関数の第一引数(ここでは `prevCount` と呼ぼう)に代入して実行する。
先ほどのコードを関数型更新で書き直してみよう。
import React, { useState } from ‘react’;
export function RobustCounter() {
const [count, setCount] = useState(0);
const handleRapidClick = () => {
// 関数型更新を使うことで、前回の更新結果が確実に引き継がれる
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
};
return (
Count: {count}
);
}
これで見事に画面上の数値は `3` になる。
Reactの内部では、更新キュー(Update Queue)に次のような処理が積まれる:
1. 初期値 `0` に対し、`prev => prev + 1` を適用 $\rightarrow$ 結果 `1`
2. その結果 `1` に対し、`prev => prev + 1` を適用 $\rightarrow$ 結果 `2`
3. その結果 `2` に対し、`prev => prev + 1` を適用 $\rightarrow$ 結果 `3`
これが、非同期環境下でも状態の整合性を保つための基本にして最強のアーキテクチャだ。
—
3. 実務の現場から:非同期API競合と状態消失のリアルな恐怖
簡単なカウンターの例だけなら教科書通りだが、実務の現場――例えば、「複数の非同期フェッチが連続して走り、その結果を配列の状態にマージしていくダッシュボード画面」などでは、この問題は致命傷になる。
以下のコードを見てほしい。ユーザーが素早く連続でフィルターを切り替えたとき、古いAPIのレスポンスが遅れて返ってきて、最新のデータを上書きして消し去ってしまう典型的なアンチパターンだ。
// ⚠️ 危険なアンチパターン:非同期処理における状態競合
function BadDataFetcher() {
const [items, setItems] = useState([]);
const fetchMoreData = async (query) => {
const newData = await api.search(query);
// itemsがクロージャ内の古い配列を参照しているため、
// 並行して走った別のリクエストの結果を吹き飛ばす可能性がある
setItems([…items, …newData]);
};
// …
}
これを関数型更新と、非同期処理特有のガード(レースコンディション対策)を組み合わせて堅牢に書き換えるとこうなる。
import React, { useState, useCallback } from ‘react’;
export function SafeDataFetcher() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
// 関数型更新を活用した安全なデータマージ
const fetchMoreData = useCallback(async (query) => {
setLoading(true);
try {
const newData = await api.search(query);
// 前回の状態(prevItems)を確実にキャプチャし、メモリ上の古いスナップショットに依存しない
setItems((prevItems) => {
// 必要に応じて重複排除のロジックなどもここで安全に書ける
const existingIds = new Set(prevItems.map(item => item.id));
const uniqueNewData = newData.filter(item => !existingIds.has(item.id));
return […prevItems, …uniqueNewData];
});
} catch (error) {
console.error(“データの取得に失敗しました”, error);
} finally {
setLoading(false);
}
}, []); // 依存配列にitemsを入れる必要がなくなるため、不要な関数の再生成も防げる
return (
);
}
アーキテクチャ的メリット:
1. クロージャの呪縛からの解放: 依存配列(`useCallback` の第二引数など)に `items` を含める必要がなくなるため、親からの再レンダリング伝播や無駄な関数の再生成(メモリ効率の悪化)を防げる。
2. メモリ効率とガベージコレクション: 余計な参照を保持しないため、V8エンジンにおけるガベージコレクションの負担を軽減し、メモリリークのリスクを最小化できる。
—
4. チーフアーキテクトからの提言:いつ関数型更新を使うべきか?
すべての `useState` で関数型更新を使う必要はない。「ユーザーの入力に直接連動する単純なフォームのテキスト管理(`setValue(e.target.value)` など)」では、現在の値を直接渡す方がシンプルで読みやすい。
しかし、以下の条件に当てはまる場合は、「関数型更新を使わないこと自体が設計上のバグ(Technical Debt)」だと認識してほしい。
- 直前の状態に依存して次の状態を計算する場合(カウンター、トグル、配列への要素追加・削除など)
- イベントハンドラや非同期処理(`setTimeout`, `Promise`, `async/await`)の中で状態を更新する場合
- カスタムフック内部で、外部から渡されたコールバックや不特定のトリガーによって状態を更新する場合
Reactのレンダリングライフサイクルと非同期の波を愛し、その挙動を完全に手なずけること。それこそが、プロダクトを破滅から救うシニアエンジニアのプライドだ。
さあ、今すぐ君のコードベースを開き、`setX(x + 1)` の亡霊たちを関数型更新で浄化してこい。

コメント