おい、ちょっといいか。最近レビューしたコードで、また例の「あれ」を踏み抜いているやつがいたんだよ。
「ボタンを連打したら、カウントが意図したより全然増えないんです」
「非同期処理が終わった後に状態を更新したら、なんか古い値が上書きしちゃってデータが消えるんです」
……お前らはまだ、`useState`の機嫌を損ねて消耗しているのか?
中級への階段を登り始めた君たちなら、そろそろ「Reactの状態更新は非同期であり、かつバッチングされる」という仕様の教科書的な説明は聞き飽きているはずだ。だが、じゃあそれを実務の現場でどう防ぐのか、その解法を正確にコードに落とし込めているか?
今回は、Reactのレンダリングの裏側で何が起きているのかを解剖し、「関数型更新(Functional Update)」という最強の武器を使って競合状態(Race Condition)をねじ伏せる実務の知見を授けよう。
—
なぜ、あなたの `useState` は裏切るのか?
まずは敵を知ることから始めよう。以下のコードを見てくれ。よくある「いいねボタン」のつもりで作ったコンポーネントだ。
// ⚠️ やりがちなアンチパターン
function BadCounter() {
const [count, setCount] = useState(0);
const handleTripleClick = () => {
// 3回連続で状態を更新したい!
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return (
カウント: {count}
);
}
このボタンをポチッと1回押したとき、画面の `count` はいくつになると思う?
「そりゃ、3増えるから `3` だろ」と思ったそこの君、甘い。画面が表示するのは `1` だ。
Reactの裏側で起じている「バッチング」の正体
React(特にReact 18以降)は、パフォーマンスを極限まで高めるために、イベントハンドラ内の複数の状態更新をまとめて1回(Batch)の再描画に処理する。これを自動バッチング(Automatic Batching)と呼ぶ。
上記の `handleTripleClick` が実行された瞬間、Reactの内部では次のようなことが起きている:
1. `setCount(count + 1)`:この時点の `count` は `0` なので、Reactは「`0 + 1 = 1` に更新するタスク」を予約する。
2. `setCount(count + 1)`:まだ再描画は走っていないため、この瞬間のクロージャ内の `count` もまだ `0` のままだ。よって「`0 + 1 = 1` に更新するタスク」がもう一丁追加される。
3. `setCount(count + 1)`:これも同様に「`0 + 1 = 1` に更新するタスク」になる。
結果として、Reactは「1にして、1にして、1にする」という無駄な命令を3回処理し、最終的な状態を `1` に確定させてしまうのだ。これが、非同期バッチングと値のキャプチャ(クロージャ)が引き起こす悲劇のメカニズムだ。
—
救世主:関数型更新(Functional Update)の真髄
この競合状態をスマートに解決するのが、`seter` 関数に「現在の最新状態を受け取って新しい状態を返す関数」を渡す関数型更新だ。
言葉で説明するより、コードを見せたほうが早いな。こう書くんだ。
// ✨ ベストプラクティス:関数型更新の活用
function GoodCounter() {
const [count, setCount] = useState(0);
const handleTripleClick = () => {
// 引数に「直前の最新状態」を受け取るコールバックを渡す
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
};
return (
カウント: {count}
);
}
これならボタンを1回押せば、画面のカウントはちゃんと `3` になる。
なぜ、これでうまくいくのか?
関数型更新を使うと、Reactは内部のキュー(Queue)に「新しい値を直接セットする命令」ではなく、「現在の状態を引数にとり、次の状態を計算する関数」を積んでいく。
1. 1つ目の更新:`prevCount`(0)を受け取り、`1` を返す。
2. 2つ目の更新:Reactは直前の計算結果である `1` を `prevCount` として次の関数に渡し、`2` を返させる。
3. 3つ目の更新:直前の `2` を `prevCount` として受け取り、`3` を返させる。
このように、Reactが内部で保持している「最新のコミットされた状態のキュー」を順番に正しく経由するため、どれだけ短時間に連続して更新が走ろうとも、絶対に値がロストしない。これがプロの現場で関数型更新が絶対的正義とされる理由だ。
—
実務で直面する「本当の地獄」:非同期処理と状態の競合
簡単なカウンターならまだ可愛いものだ。実務で一番恐ろしいのは、「APIリクエストやタイマーなどの非同期処理が絡むシーン」だ。
例えば、ショッピングカートのアイテム数を管理するコンポーネントを考えてみよう。ユーザーが「追加」ボタンを猛烈なスピードで連打したとき、バックエンドへの非同期リクエストが絡むと、状態の整合性が一瞬で崩れ去る。
以下の実用的なコードを見てほしい。これが、現場で戦える「正しい状態管理」のサンプルだ。
import React, { useState } from ‘react’;
// ダミーの非同期API(ネットワーク遅延をシミュレート)
const updateCartApi = async (newCount: number): Promise
return new Promise((resolve) => {
setTimeout(() => {
resolve(newCount);
}, 500);
});
};
export const ShoppingCart = () => {
const [cartCount, setCartCount] = useState
const [isLoading, setIsLoading] = useState
const [errorMessage, setErrorMessage] = useState
// カートにアイテムを追加するハンドラ(非同期+連打対策)
const handleAddToCart = async () => {
setIsLoading(true);
setErrorMessage(null);
try {
// 🔴 ここで通常の値(cartCount + 1)を使うと、非同期処理の間に
// 別のアクションで状態が変わったときに古い値で上書きされてしまう。
// 必ず関数型更新を使い、現在の最新値をもとに次の値を安全に計算する。
let nextCount = 0;
setCartCount((prevCount) => {
nextCount = prevCount + 1;
return nextCount;
});
// 最新の計算されたカウントをAPIに送信
// (※実際はAPIのレスポンスを信頼するか、楽観的UI更新として扱うかの設計による)
await updateCartApi(nextCount);
} catch (error) {
setErrorMessage(‘カートの更新に失敗しました。もう一度お試しください。’);
// エラー時はロールバック(状態を元に戻す)処理が必要になることもある
} finally {
setIsLoading(false);
}
};
return (
ショッピングカート
カート内のアイテム数: {cartCount}
{isLoading &&
サーバーと通信中…
}
{errorMessage &&
{errorMessage}
}
);
};
チーフアーキテクトからの実践的なアドバイス
1. 「前の状態に依存する更新」は、例外なく関数型を使え
`state + 1` や `[…state, newItem]` のように、今の状態が何であるかに依存して次の状態を決めるコードを書くときは、思考停止で `setState(prev => …)` を使え。これだけで、将来発生する謎のバグの8割は防げる。逆に、ユーザーの入力値をそのままブチ込むような「前の状態に依存しない更新(例: `setName(e.target.value)`)」は、通常の書き方で問題ない。
2. 複雑な競合は `useReducer` への移行を検討しろ
もし1つのコンポーネント内で、あまりにも多くの状態が複雑に絡み合い、お互いに依存し合っているなら、それは `useState` の限界だ。おとなしく `useReducer` を導入し、状態遷移のロジックをリデューサー関数に閉じ込めろ。コードの見通しが劇的に良くなるはずだ。
—
まとめ
Reactの `useState` はシンプルに見えて、その裏側では非同期処理やクロージャの罠が牙を剥いている。
- 短時間の連続更新や非同期処理では、状態の競合(Race Condition)が起きる。
- `setCount(prev => prev + 1)` のような関数型更新を用いることで、React内部の確実なキューをたどり、安全に最新の状態を維持できる。
現場でコードを書くときは、「この瞬間の変数の値は、本当に信用できるか?」と一歩立ち止まって疑うクセをつけてほしい。その泥臭い慎重さこそが、君を一流のフロントエンドエンジニアに引き上げる最大の武器になる。
それじゃ、今日もセキュアで美しいコードを頼むぞ。

コメント