【実務・中級編】 React状態更新の非同期性とバッチ処理 – React実践ガイド

こんにちは。君、最近こんなコード書いて「あれ?」って頭を抱えなかったかい?

const [count, setCount] = useState(0);

const handleClick = () => {
setCount(count + 1);
console.log(count); // 「あれ、まだ0のままやんけ!」
};

実務でReactを触り始めると、100人中100人がここで一度はハマる。setStateした直後に値を見に行っても、画面の描画はもちろん、コンソールログすら古い値のままで絶望する。

「Reactのバグか?」いや違う。これはReactが君たちのアプリのパフォーマンスを守るために裏で必死こいてやってくれている「優しさ」なんだ。今日はこの状態更新の非同期性とバッチ処理の裏側を、シニアの視点から徹底的に剥ぎ取って解説しよう。これさえ押さえれば、もう「値が同期しない」なんて夜中に冷や汗をかくことはなくなるはずだ。

—

1. なぜ状態の更新は「即時反映」されないのか?

まず大前提として、Reactの `useState` による状態更新は「予約(リクエスト)」であって、その場で変数を書き換えるマジックではない。

JavaScriptの通常の変数であれば、`count = count + 1` と書けば次の行では即座に `count` は更新される。しかし、Reactのコンポーネントは「UIの関数的表現(`UI = f(state)`)」だ。状態が変わるたびにコンポーネントの関数全体が再実行され、仮想DOMを構築し、差分を計算して、実際のDOMに反映する……という一連のライフサイクルが走る。

もし、`setCount` を呼ぶたびに即座にこの重いレンダリングプロセスが走ったらどうなる?
1つのイベントハンドラ内で3回ステートを更新したら、画面の再描画が3回も発生する。ブラウザはフリーズし、ユーザーはカクつく画面を見せつけられてアプリをアンインストールするだろう。

だからこそ、Reactは「おい、ちょっと待て。今から来る変更をまとめて処理するから、とりあえずその値は後でまとめて計算するわ」と、更新を非同期に(正確にはバッチ(一括)処理として)キューに溜め込む仕様になっているんだ。

—

2. React 18の「自動バッチ処理(Automatic Batching)」とは?

ここで、React 18で導入された最大のパラダイムシフト「Automatic Batching(自動バッチ処理)」の話をしよう。

React 17以前でもバッチ処理はあった。しかし、それはReactのイベントハンドラ(`onClick` など)の中だけでしか有効じゃなかった。`setTimeout` や `Promise` のコールバック、ネイティブのイベントリスナーの中ではバッチされず、非同期処理の数だけレンダリングが走っていたんだ。

しかし、React 18以降は、何処で状態が更新されようとも、一律でバッチ処理されるようになった。

以下のコードを見てほしい。

const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);

const handleAsyncClick = async () => {
// 非同期処理(例:API通信のモック)
await fetchSomething();

// React 17以前ならここで2回レンダリングが走る
// React 18以降なら、これらは1つのバッチにまとめられ、レンダリングは「たったの1回」!
setCount(c => c + 1);
setFlag(f => !f);
};

「おっ、無駄な再描画が減って最高じゃん!」と思ったかい?その通り、パフォーマンスの観点では神機能だ。
しかし、ここに「最新の値を使ったつもりが古い値だった」という罠が潜んでいる。バッチ処理の裏側では、複数のステート更新がひとまとめに予約されるため、更新関数(Updater)を使わずに直接値を渡していると、予期せぬ古いステート(Stale State)をキャプチャしてしまうことがあるんだ。

—

3. 実務で使える!「更新関数」と「useEffect」の正しい使い分け

じゃあ、実務でこの非同期性とどう向き合えばいいのか。解決策はシンプルで強力だ。

① 連続した更新には「更新関数(Updater Function)」を使う

前の状態をベースに次の状態を計算する場合は、直接値を渡すのではなく、コールバック(更新関数)を渡せ。これがReactの鉄則だ。

// ❌ やりがちなアンチパターン(値が正しく加算されないことがある)
const handleBadIncrement = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 結果:3増えず、1しか増えない(古い count を参照し続けているため)
};

// ⭕️ 正しいプロの書き方
const handleGoodIncrement = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
// 結果:綺麗に3増える。Reactがキューから最新の値を順番に渡してくれるため
};

② ステート変更の「直後」の値を使いたいときはどうする?

「ステートを変えた直後の値を使って、何か別の処理(APIリクエストやロジックの実行)をしたい」という場面は実務でもよくある。そんなときは、`useState` の即時性を信じるのではなく、`useEffect` か、あるいは「一時変数」に逃がすのがセオリーだ。

以下のコードは、現場でそのまま使えるクリーンなサンプルだ。コピーして挙動を確認してみてほしい。

import React, { useState, useEffect } from ‘react’;

export const CounterComponent = () => {
const [count, setCount] = useState(0);

const handleIncrementWithSideEffect = () => {
// 1. 更新関数を使って安全にカウントを進める
setCount(prev => {
const nextCount = prev + 1;

// 【Tips】どうしても即座に最新値を使いたいロジックがあるなら、
// このスコープ内のローカル変数(nextCount)をそのまま使うのが最も安全。
console.log(‘更新直後のローカル値:’, nextCount);

return nextCount;
});
};

// 2. ステートの変化をトリガーに副作用を実行したい場合
useEffect(() => {
// count が更新された後に必ず走る(描画完了後)
console.log(‘useEffectで検知した最新のcount:’, count);

// ここでAPIを叩くなどの処理を行う
}, [count]);

return (

);
};

—

4. シニアからのまとめ・アドバイス

Reactの状態管理における非同期性と自動バッチ処理は、敵ではなく「最高のパフォーマンスを引き出すための強力な相棒」だ。

  • 状態の更新は「予約」であって即時ではない。
  • React 18の自動バッチングにより、非同期処理後であっても複数回の更新は一括処理される。
  • 次の状態を計算するときは、必ず `setX(prev => prev + 1)` の形式(更新関数)を使え。
  • 直後の値に依存した処理が必要なときは、スコープ内のローカル変数で保持するか、`useEffect` を正しく使え。

この基本原則が頭と体に染み込んでいれば、どれほど複雑なフォームの状態管理や非同期フローが来ても、バグを踏むことはなくなるはずだ。
さあ、エディタを開いて、自分のコードの `useState` たちを見直してみようか!

コメント

タイトルとURLをコピーしました