やあ。最近、チームのコードレビューをしていて「またか」と天を仰ぎたくなる瞬間があるんだ。
それが今回テーマにする`useState`の非同期バグ、そしてそれを鮮やかに解決する「関数型更新(Functional Updates)」の不徹底によるトラブルだ。
中級への階段を登り始めたエンジニアによくあるのが、「画面がなぜか一拍遅れて更新される」「連続クリックしたときにカウントがずれる」という謎の不具合に直面して、useEffectの依存配列をいじくり回して沼にハマるパターン。
大体ね、そういう時の原因はReactのレンダリングの仕組みと非同期バグの本質を分かっていないことにある。
今日は、現場のシニアとして、なぜこの問題が起きるのか、ブラウザの裏側でReactがどう動いているのか、そしてどうコードを書き換えるべきかを徹底的に叩き込んでやろう。心して聞いてくれ。
—
なぜ `useState` の通常更新は罠だらけなのか?
まずは敵を知ることから始めよう。
例えば、ボタンを連打すると「3ずつカウントが増える」ような雑なカウンターを作ったとする。君ならどう書く?
// 良くある、そしてバグを生みがちなコード
function BadCounter() {
const [count, setCount] = useState(0);
const handleTripleIncrement = () => {
// 3回呼べば 3 増えるはず…?
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return (
Count: {count}
);
}
さて、このボタンを押したとき、`count` はいくつになると思う?
答えは 「1」 だ。驚いたかい? 3じゃないんだよ。
Reactの裏側で何が起きているのか?
この現象を紐解く鍵は、「状態更新の非同期性(バッチ処理)」と「クロージャの性質」にある。
1. `setCount(count + 1)` の正体:
Reactの `useState` が返すセッター関数は、その場で直ちに `count` の値を書き換えるわけじゃない。Reactの内部キューに「次のレンダリングでこの状態をこう変えたい」というリクエストを溜め込んでいるだけなんだ。これをパフォーマンス最適化のために一定期間まとめて処理する(バッチ処理)。
2. 古い変数を参照するクロージャ:
関数コンポーネントが実行された瞬間、その中の `count` はそのレンダー時点での固定された値(例えば `0`)を指している。
上記の `handleTripleIncrement` が実行された瞬間、関数の中身は実質的にこう解釈されている。
setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);
そりゃ、同じ `0 + 1` を3回実行しているんだから、何度呼ぼうが結果は `1` になるに決まっているよね。
実務では、これがAPIのレスポンス処理や、非同期のイベントリスナー、タイマー処理の中で起きて、データが巻き戻ったり消えたりする「頭を抱えたくなるバグ」の原因になる。
—
救世主:「関数型更新(Functional Updates)」の出番だ
この問題を一刀両断するのが、`setState` の引数に「前の状態を引数にとるコールバック関数」を渡すテクニック、関数型更新だ。
書き方はこうだ。`setCount(prev => prev + 1)`。
先ほどのダメなカウンターを、関数型更新を使って正しく書き直してみよう。現場ですぐに使えるレベルの、堅牢なコンポーネントのサンプルだ。
import React, { useState } from ‘react’;
export function GoodCounter() {
const [count, setCount] = useState(0);
const handleTripleIncrement = () => {
// prev(直前の最新の状態)を必ず受け取って計算する
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
};
return (
堅牢なカウンター(関数型更新)
現在のカウント: {count}
{/
このボタンを連打、あるいは1回のクリックで3回加算を実行しても、
Reactはキューに積まれた最新の `prev` を順番に評価するため、確実に 3 ずつ増える。
/}
);
}
これの何が素晴らしいかと言うと、Reactが内部のキューを処理する際、「直前の更新結果が反映された最新の `prev`」を次の関数の引数に必ず渡してくれるようになる点だ。
つまり、Reactのスケジューリングやレンダリングのタイミングに依存せず、データの整合性が完全に保たれる。
—
現場で役立つ!関数型更新を適用すべき実務パターン
「理屈は分かったけど、いつこれを使えばいいの?」という疑問が湧く頃だね。
シニアの経験から言うと、以下の3つのシチュエーションに出くわしたら、迷わず関数型更新を思い出してほしい。
1. フォームの複数項目の更新や、複雑なオブジェクトのイミュータブルな更新
散らばった入力値を1つのstate(オブジェクト)で管理している時、前の状態を確実にマージしないとデータがぶっ飛ぶ。
const [form, setForm] = useState({ name: ”, email: ”, age: 0 });
// 悪い例:非同期のタイミングで他のフィールドが吹き飛ぶリスクがある
// setForm({ …form, name: ‘Taro’ });
// 良い例:関数型更新で安全にマージする
const updateName = (newName) => {
setForm(prevForm => ({
…prevForm,
name: newName
}));
};
2. 配列への要素の追加・削除(Todoアプリやカート機能など)
ユーザーが素早くボタンを連打したとき、配列のステートが古いまま上書きされるのを防ぐ。
const [todos, setTodos] = useState([]);
const addTodo = (newText) => {
setTodos(prevTodos => [
…prevTodos,
{ id: Date.now(), text: newText, completed: false }
]);
};
3. カスタムフック内や、クロージャが絡むイベントリスナー内
`useEffect` の中でイベントリスナーを登録し、その中で状態を更新する場合、依存配列に `count` を入れたくない(再登録コストを抑えたい)というケースがあるよね。
そんな時も、関数型更新を使っていれば `count` を依存配列から外すことができる(=不要なリスナーの再登録を防ぎ、パフォーマンスが劇的に向上する)。
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === ‘ArrowUp’) {
// countを依存配列に入れなくても、常に最新の値を安全にインクリメントできる
setCount(prev => prev + 1);
}
};
window.addEventListener(‘keydown’, handleKeyDown);
return () => window.removeEventListener(‘keydown’, handleKeyDown);
}, []); // 依存配列が空でもバグらない!
—
まとめ:シニアからのアドバイス
Reactの `useState` における関数型更新は、単なる「お作法」じゃない。
「非同期で動くUIライブラリにおいて、データの不変性と整合性を担保するためのプロの防衛策」なんだ。
明日からコードを書くときは、`setState(value)` と直書きしそうになったら、指を止めてこう自問してほしい。
「この状態は、直前の状態(prev)に依存していないか?」
「もし連打されたり、非同期処理が挟まったりしたら、この値は古くないか?」
もし少しでも不安要素があったら、迷わず `setState(prev => …)` に書き換えること。
それだけで、君の書くReactコードの品質はワンランク上に跳ね上がるはずだ。
さあ、今日の学びを次のプルリクエストから早速活かしてくれよ。期待しているぞ!

コメント