こんにちは。チームのコードレビューをしていて、最近一番「あぁ、惜しいな、でもこれ中級の壁よね」と思う瞬間がある。それが、`useState`の更新処理で罠にハマっているコードを見たときだ。
例えば、こんなコードを書いたことはないだろうか?
// カウントを2回連続で増やそうとしたナイーブな実装
const handleDoubleIncrement = () => {
setCount(count + 1);
setCount(count + 1);
};
「ボタンを押したら2増えるはず」——そう期待して画面を見たら、1しか増えていない。ここで頭に「?」が浮かび、コンソールを漁り始め、最終的に「Reactってなんかバグってないか?」と疑心暗鬼になる。これが中級エンジニアが一度は通る通過儀礼だ。
結論から言おう。Reactは壊れていない。君のコードが、Reactの「状態更新の非同期性とバッチ処理」という本質的な仕様をまだ完全に手懐けられていないだけだ。
今日は、この泥沼から一瞬で抜け出すための最強の武器、「関数型更新(Updater Function)」について、裏側のメカニズムを含めて徹底的に叩き込んでいこう。
—
1. なぜ「直接の値渡し」は裏切るのか?(Reactの裏側で起きていること)
まず、大前提として知っておいてほしい。Reactの `useState` が返すセッター関数(例えば `setCount`)は、呼び出した瞬間にstateの値を書き換える魔法の関数ではない。
Reactは、パフォーマンスを極限まで最適化するために、イベントハンドラ内で行われた複数の状態更新を一つの処理の塊(バッチ:Batch)としてまとめる。これを「自動バッチング(Automatic Batching)」と呼ぶ。
先ほどのコードをもう一度見てほしい。
const handleDoubleIncrement = () => {
setCount(count + 1); // この時点の count が「0」だと仮定する
setCount(count + 1); // この時点の count も「0」だ
};
JavaScriptのクロージャの性質上、この関数が実行された瞬間、`count` という変数が持っている値は、そのレンダーサイクルにおける「スナップショット(固定された値)」でしかない。つまり、Reactの内部で実際に行われている処理はこうだ。
1. `setCount(0 + 1)` をキュー(予約リスト)に入れる。
2. もう一度 `setCount(0 + 1)` をキューに入れる。
3. イベントハンドラの処理が終わったあと、Reactがまとめて処理をするが、中身は `1` と `1` なので、最終的な結果はただの `1` になる。
これが、「状態が古いまま上書きされる」というバグの正体だ。非同期処理(`setTimeout`や`fetch`のコールバックなど)の中でも、このスナップショット問題は牙を剥く。
—
2. 救世主「関数型更新(Updater Function)」の仕組み
この問題を鮮やかに解決するのが、関数型更新だ。
`setCount` に直接値を入れるのではなく、「現在の最新の状態を引数にとり、次の状態を返す関数」を渡す。
const handleDoubleIncrement = () => {
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
};
何が変わったのか?
Reactは、関数型更新が渡された場合、それを単なる値ではなく「更新キューに追加するタスク(Reducer的な概念)」として扱う。
1. 1回目の `setCount` は、「現在の値(仮に0)に1を足す関数」をキューに入れる。
2. 2回目の `setCount` は、「1回目の処理が終わったあとの最新の値」を引数に取れるように準備された関数をキューに入れる。
3. Reactがバッチ処理を実行する際、キューにたまっている関数を順番に実行していく。
- 最初の関数:`0 + 1 = 1`
- 2番目の関数:さっき計算された `1` を受け取り、`1 + 1 = 2`
こうして、見事に期待通りの `2` が達成される。これが関数型更新の仕組みだ。
—
3. 【実務編】コピペで使える!現場で頻出する実践パターン
理屈はわかったところで、実際の開発現場でどう使うのかを見ていこう。ここからは、中級からシニアへステップアップするために絶対に押さえておきたい3つのパターンを紹介する。
パターンA:配列の要素追加(ToDoアプリなど)
実務で最も多いのがこれだ。古い状態をそのままスプレッド構文で展開すると、非同期の競合でデータが消えることがある。
import React, { useState } from ‘react’;
export const TodoList = () => {
const [todos, setTodos] = useState([‘Reactを学ぶ’, ‘TypeScriptを極める’]);
const [input, setInput] = useState(”);
const addTodo = () => {
if (!input.trim()) return;
// ❌ 避けるべき書き方(todosが古いスナップショットを参照するリスクがある)
// setTodos([…todos, input]);
// ⭕ 推奨される書き方:関数型更新で常に最新の配列を担保する
setTodos((prevTodos) => […prevTodos, input]);
setInput(”); // 入力欄をクリア
};
return (
placeholder=”新しいタスク”
/>
-
{todos.map((todo, index) => (
- {todo}
))}
);
};
パターンB:複雑なオブジェクトの状態更新
フォームの入力値や、複数のプロパティを持つ設定オブジェクトを扱うときも、関数型更新は強力だ。スプレッド構文と組み合わせることで、意図しないプロパティの消失を防げる。
import React, { useState } from ‘react’;
export const UserSettings = () => {
const [settings, setSettings] = useState({
theme: ‘dark’,
notifications: true,
email: ‘user@example.com’,
});
// 複数の設定変更が立て続けに起きても安全なハンドラ
const toggleNotification = () => {
setSettings((prevSettings) => ({
…prevSettings, // 既存のプロパティ(themeやemail)を絶対に破壊しない
notifications: !prevSettings.notifications, // 対象のプロパティだけを安全に反転
}));
};
return (
テーマ: {settings.theme}
通知: {settings.notifications ? ‘ON’ : ‘OFF’}
);
};
パターンC:API通信(非同期処理)のコールバック内での安全な状態更新
非同期通信の完了を待つ間に、コンポーネントの別の要因でステートが変わる可能性を考慮すると、APIのレスポンス処理こそ関数型更新の出番だ。
import React, { useState } from ‘react’;
export const CartCounter = () => {
const [cartCount, setCartCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const handleAddToCartApi = async () => {
setIsLoading(true);
try {
// 擬似的なAPI通信(遅延を発生させる)
await new Promise((resolve) => setTimeout(resolve, 1000));
// サーバーから「1つ追加された」という前提で、現在のカウントをベースにインクリメント
// もし通信中の間に別の処理でカウントが変わっていても、この関数型更新なら安全に追従できる
setCartCount((prevCount) => prevCount + 1);
} catch (error) {
console.error(‘カートの更新に失敗しました’, error);
} finally {
setIsLoading(false);
}
};
return (
カート内のアイテム数: {cartCount}
);
};
—
4. シニアからのアドバイス:いつ関数型更新を使うべきか?
「じゃあ、すべての `set` で関数型を書いた方がいいのか?」と聞かれることがあるが、それは過剰防衛だ(オーバーエンジニアリング)。
以下の基準でコードベースを点検してほしい。
1. 「直前の状態(Previous State)」に依存して次の状態を計算しているか?
- 答えが Yes なら、迷わず関数型更新を使え。(例: `count + 1`, `[…list, item]`, `prev ? false : true`)
2. 親から渡されたpropsや、ただの固定値(定数やフォームへの単純な入力値の上書き)をそのままセットしているか?
- 答えが Yes なら、従来の直接値渡しで十分だ。(例: `setIsOpen(true)`, `setName(e.target.value)`)
特にカスタムフックを書くときや、複雑な状態遷移を持つコンポーネントを設計するとき、この「関数型更新」の原則を体に染み込ませているかどうかが、バグの少ない堅牢なコードを書けるエンジニアと、そうでないエンジニアの分かれ目になる。
今日の帰り道、あるいは次のチケットのコーディングで、ぜひ自分の `useState` の使い方を見直してみてほしい。「あ、ここ、前の状態に依存してるから関数型にすべきだな」と脊髄反射で書けるようになったら、君はもう一皮むけた中級エンジニアの領域に到達しているはずだ。
さあ、エディタを開いて、安全で美しいコードを書きにいこう。

コメント