React状態更新の非同期性とバッチ処理の深層:なぜ「今書き換えた値」がすぐに手に入らないのか
こんにちは。日々、数百万人のユーザーを抱える巨大なReactアプリケーションのパフォーマンスチューニングとアーキテクチャ設計に頭を悩ませているフロントエンド・アーキテクトです。
コードレビューをしていて、もっとも頻繁に見かける、そして最も厄介なバグの温床は何だと思いますか?
それは、「`useState`で状態を更新した直後に、その値が即座に変わっていると思い込むこと」です。
// 多くのジュニア、そして時々シニアすらも引っかかる罠
const handleIncrement = () => {
setCount(count + 1);
console.log(count); // え?なんで増えてないの?(非同期の罠その1)
sendAnalytics(count); // ここで古い値が送信される悲劇
};
ブラウザのメインスレッドで何が起きているのか、JavaScriptのイベントループ、そしてReact 18で導入された「自動バッチ処理(Automatic Batching)」の内部メカニズムにまで踏み込んで、この謎を完全に解き明かしましょう。
—
1. なぜReactの状態更新は「即時」ではないのか?
まず大前提として、`useState`のセッター関数(例: `setCount`)は、「状態をその場で書き換える命令」ではありません。 これはReactのスケジューラーに対する「再描画の予約リクエスト」に過ぎません。
もし`setCount`が呼ばれるたびに即座にDOMツリーの差分計算(Reconciliation)とコミットが走ったらどうなるでしょうか? ブラウザは常に再描画の嵐に見舞われ、メインスレッドは完全にブロックされ、ユーザーの入力に対してカクつく最悪のアプリケーションが完成します。
Reactはパフォーマンスを極限まで保つために、状態の変更をバッチ(束ねて)処理し、適切なタイミングで一括してレンダリングを行います。この「遅延と最適化」こそがReactのコア哲学です。
連続する更新とクロージャの罠
次のコードを見てください。あなたはこのボタンを連打したとき、カウントがどう増えると思いますか?
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// 3回連続で呼んだつもり
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return ;
}
答えは、「1しか増えない」です。
なぜなら、`handleClick`が実行された瞬間、関数内の `count` はクロージャによってキャプチャされた「現在のレンダーサイクルにおける値(例えば `0`)」のままだからです。実質的にReactが受け取ったリクエストは以下のようになります。
setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);
最後の `1` をセットする命令で古い命令が上書きされてしまうため、何度呼んでも結果は同じになるのです。これが「非同期性と状態の競合」が生む最初のトラップです。
—
2. React 18の「自動バッチ処理(Automatic Batching)」がもたらしたパラダイムシフト
React 17以前でも、Reactのイベントハンドラ内での状態更新はバッチ処理されていました。しかし、`setTimeout`や`Promise`のコールバック、ネイティブのイベントリスナー内ではバッチ処理されず、非同期処理の数だけレンダリングが発生していました。
React 18以降、根底から挙動が変わりました。 `createRoot`を使用しているアプリでは、どこで状態が更新されようとも、一律でバッチ処理される「Automatic Batching」がデフォルトで有効になっています。
// React 18以降の非同期イベント内
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// これらは個別にレンダリングを引き起こさず、
// イベントループの1つのtickの終わりに「1回だけ」まとめられます。
}, 1000);
これはメモリ効率とレンダリング負荷の観点からは革命的な改善です。無駄な再描画(Re-render)が劇的に減り、CPUサイクルを節約できます。
しかし、アーキテクト視点では新たな注意が必要です。バッチ処理の境界を意識していないと、「データの整合性が崩れる瞬間」に直面します。複数の非同期処理が入り乱れる複雑なフォームやデータグリッドでは、バッチングのタイミングを誤認したことによる「幽霊のようなバグ」が発生しやすくなります。
—
3. 実務で使える堅牢な回避策とアーキテクチャパターン
では、この非同期性とバッチングの荒波を乗りこなし、堅牢なアプリケーションを構築するためにはどうすればよいのでしょうか。実務で即座に使えるテクニックをいくつか紹介します。
① 関数型アップデート(Functional Updates)の徹底
古い状態を参照して新しい状態を計算する場合、必ずセッターに関数を渡してください。これはReactの基本ですが、実務での徹底度がコードの品質を左右します。
// ❌ 危険:現在のクロージャ内の count に依存している
const handleBadIncrement = () => {
setCount(count + 1);
setCount(prev => prev + 1); // 混在させるとバグの温床に
};
// 完璧:常に最新のキューから状態を計算する
const handleGoodIncrement = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
// これなら確実に「+3」される
};
② `useEffect` を状態同期の「逃げ道」に使わない
「状態が変わった直後に何かを実行したい」という動機から、つい `useEffect([count], () => { … })` を書きがちです。しかし、これは多くの場合アンチパターンです。レンダリングが1回余分に走る原因になり、パフォーマンスを劣化させます。
状態更新の「直後」の最新値が必要な場合は、変数をローカルに保持して処理を継続します。
const handleSubmit = async () => {
// ローカル変数で最新値を担保しつつ更新を呼ぶ
const nextCount = count + 1;
setCount(nextCount);
// 更新直後の値を使ってAPIリクエストを飛ばす
await api.updateCount(nextCount);
};
③ どうしても即時反映・同期が必要な場合の最終手段:`flushSync`
React 18では、バッチ処理を強制的に破棄し、即座にDOMの更新と再レンダリングを行わせる `flushSync` という脱出ハッチ(Escape Hatch)が提供されています。
※ただし、これは本当に例外的なケース(ブラウザのAPIとの統合や、サードパーティのDOM操作ライブラリとの連携など)に限定して使うべきです。多用するとReactのパフォーマンスメリットが完全にスポイルされます。
import { flushSync } from ‘react-dom’;
const handleCriticalUpdate = () => {
// この中の状態更新は即座にDOMに反映される
flushSync(() => {
setCount(c => c + 1);
});
// この行に到達した時点で、DOMはすでに新しい count で再描画されている
console.log(domNode.getBoundingClientRect());
};
—
まとめ:フレームワークと対話するエンジニアであれ
Reactの状態管理は、単なる「変数の入れ替え」ではありません。ブラウザのレンダリングパイプラインとJavaScriptの非同期処理モデルの狭間で、いかに効率よくUIを同期させるかという、高度なエンジニアリングの結晶です。
「なぜ今、値が更新されていないのか?」
「このコードは、次のレンダーサイクルでどのように評価されるのか?」
こうした疑問を常に頭の片隅に置き、フレームワークの内部挙動を愛でるような視点を持つことで、あなたの書くReactコードは、大規模・高負荷な環境でもビクともしない、真に堅牢なアーキテクチャへと昇華されるはずです。
さあ、エディタを開いて、あなたのコードベースにある「非同期の罠」を探しに行きましょう。

コメント