Reactの自動バッチ処理でstateの更新がまとまる条件
React 18 以降では、createRoot で作ったルートなら、setTimeout や Promise の中で続けて呼んだ state の更新も、1回の描画にまとめられます。これが自動バッチ処理です。React 17 や、React 18 でも ReactDOM.render のままの場合は、同じ setTimeout や Promise の中の更新が、1つずつ描画されます。
筆者は検証環境で、2つの state を同じ書き方で更新するコンポーネントを React 17・18・19 で動かし、描画の回数を数えました。まとめられない書き方(途中に await があるとき、flushSync を使ったとき、別々のクリック)も確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 17.0.2、18.3.1、19.3.1、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、jsdom と Chromium 141(Playwright 1.56.1 で操作)で動かしました。StrictMode では包んでいません。
バッチ処理とは、更新を1回の描画にまとめること
React 18 のアップグレードガイドにある自動バッチ処理の説明では、バッチ処理を「複数の state の更新を、1回の再描画にまとめること」としています。React 18 より前にまとめられていたのは、React のイベントハンドラの中の更新だけでした。Promise、setTimeout、ブラウザの addEventListener で登録したイベントハンドラの中の更新は、まとめられていませんでした。
React 18 からは、createRoot を使えば、どこから呼んだ更新もまとめられます。同じガイドには、自動バッチ処理を使いたくない更新には flushSync を使う、とも書かれています。
描画の回数を数えるコンポーネント
count と flag の2つの state を持ち、描画のたびに記録を残すコンポーネントを作りました。updateBoth は2つの state を続けて更新し、その直後に、画面に出ている内容を ref で読みます。この関数を、React のイベントハンドラ、setTimeout、Promise、addEventListener のそれぞれから呼びます。
import { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { say } from './log';
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 2つの state を、いろいろな場所から更新して、描画の回数を数える
export function BatchProbe() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const shownRef = useRef<HTMLParagraphElement>(null);
const nativeRef = useRef<HTMLButtonElement>(null);
say(` 描画: count=${count} flag=${flag}`);
// set を呼び終えた時点で、画面に出ている内容を読む
function readScreen() {
say(` set を呼び終えた時点の画面: ${shownRef.current!.textContent}`);
}
function updateBoth() {
setCount((c) => c + 1);
setFlag((f) => !f);
readScreen();
}
// React を通さずに登録したイベントリスナー
useEffect(() => {
const button = nativeRef.current!;
button.addEventListener('click', updateBoth);
return () => button.removeEventListener('click', updateBoth);
}, []);
return (
<>
<p ref={shownRef}>count={count} flag={String(flag)}</p>
<button id="event" onClick={updateBoth}>イベントハンドラ</button>
<button id="timeout" onClick={() => setTimeout(updateBoth, 0)}>setTimeout</button>
<button id="promise" onClick={() => Promise.resolve().then(updateBoth)}>Promise</button>
<button id="native" ref={nativeRef}>addEventListener</button>
<button
id="await"
onClick={async () => {
setCount((c) => c + 1);
await wait(10);
setFlag((f) => !f);
}}
>
途中で await
</button>
<button
id="flush"
onClick={() => {
flushSync(() => {
setCount((c) => c + 1);
});
setFlag((f) => !f);
}}
>
flushSync
</button>
</>
);
}
say は、記録を配列にためるだけの関数です。テストのコードは jsdom の上でボタンを1つずつクリックし、50ms 待ってから、そのあいだの記録と描画の回数を出します。React 17・18 の ReactDOM.render 版と、React 18・19 の createRoot 版の4つで、同じコンポーネントのファイルを使いました。
createRoot では、どこから呼んでも1回にまとまる
React 19 の createRoot で動かした結果のうち、前半の4つです。
[イベントハンドラ]
set を呼び終えた時点の画面: count=0 flag=false
描画: count=1 flag=true
→ 描画 1回
[setTimeout]
set を呼び終えた時点の画面: count=1 flag=true
描画: count=2 flag=false
→ 描画 1回
[Promise]
set を呼び終えた時点の画面: count=2 flag=false
描画: count=3 flag=true
→ 描画 1回
[addEventListener]
set を呼び終えた時点の画面: count=3 flag=true
描画: count=4 flag=false
→ 描画 1回
4つとも、描画は1回でした。count と flag が両方とも変わった状態で、1回だけ描画されています。
もう1つ分かるのは、描画のタイミングです。set を2つ呼び終えた時点では、画面はまだ更新の前の内容のままでした。描画は、更新を呼んだ関数が終わった後に、まとめて行われています。state の更新をまとめる仕組みを説明した公式ドキュメントにも、React はイベントハンドラの中のコードがすべて終わってから state の更新を処理する、とあります。
React 18.3.1 の createRoot でも、結果は React 19 と同じでした。
React 17と、React 18のReactDOM.renderではまとまらない
同じコンポーネントを、React 17 の ReactDOM.render で動かした結果です。
[イベントハンドラ]
set を呼び終えた時点の画面: count=0 flag=false
描画: count=1 flag=true
→ 描画 1回
[setTimeout]
描画: count=2 flag=true
描画: count=2 flag=false
set を呼び終えた時点の画面: count=2 flag=false
→ 描画 2回
[Promise]
描画: count=3 flag=false
描画: count=3 flag=true
set を呼び終えた時点の画面: count=3 flag=true
→ 描画 2回
[addEventListener]
描画: count=4 flag=true
描画: count=4 flag=false
set を呼び終えた時点の画面: count=4 flag=false
→ 描画 2回
React のイベントハンドラの中では1回にまとまりました。一方、setTimeout、Promise、addEventListener からの更新では、描画が2回ありました。1回目の描画では count だけが新しく、flag は前の値のままです。set を1つ呼ぶたびに、その場で描画が終わっています。そのため、set を呼び終えた時点で、画面はもう新しい内容になっていました。
React 18.3.1 で ReactDOM.render を使った場合も、React 17 と同じ結果でした。このとき、開発用のビルドでは次の警告が出ました。
Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot
警告のとおり、createRoot に移るまでは React 17 と同じ動きになります。なお、React 19 のアップグレードガイドにあるとおり、ReactDOM.render は React 19 で削除されました。
次の GIF は、同じページに React 17 と React 19 のアプリを並べ、setTimeout の中で2つの state を更新するボタンを3回押したところです。ボタンは React を通さない普通のボタンで、押すと両方のアプリで更新が始まります。描画の回数には、最初の表示の1回が含まれています。

Chromium で試したところ、3回押した後の描画の回数は、React 17 が7回、React 19 が4回でした。
まとめられない書き方
createRoot でも、更新が1回の描画にまとまらない場合があります。React 19 の結果の後半です。
[途中で await]
描画: count=5 flag=false
描画: count=5 flag=true
→ 描画 2回
[flushSync]
描画: count=6 flag=true
描画: count=6 flag=false
→ 描画 2回
途中に await を挟むと、描画は2回になりました。await の前の setCount だけで1回描画され、待ち終わった後の setFlag で、もう1回描画されています。1回目の描画では count だけが新しく、flag は前の値のままです。
flushSync で包むと、中の setCount だけで1回描画され、その後の setFlag で、もう1回描画されました。合わせて2回です。flushSync の公式リファレンスには、渡した関数の中の更新を同期的に処理し、DOM をその場で更新する、とあります。ただし、同じリファレンスの注意点には、flushSync はアプリの性能を大きく下げることがあるので、使うのは控えめにするように、と書かれています。
別々のクリックも、まとめられません。Chromium で React 19 のページを開き、Playwright でイベントハンドラのボタンを2回クリックしました。
[page.click で2回クリック]
set を呼び終えた時点の画面: count=0 flag=false
描画: count=1 flag=true
set を呼び終えた時点の画面: count=1 flag=true
描画: count=2 flag=false
→ 描画 2回
[page.dblclick(ダブルクリック)]
set を呼び終えた時点の画面: count=2 flag=false
描画: count=3 flag=true
set を呼び終えた時点の画面: count=3 flag=true
描画: count=4 flag=false
→ 描画 2回
ダブルクリックでも、クリックごとに1回ずつ描画されました。先ほどの state の更新をまとめる仕組みを説明した公式ドキュメントでも、クリックのように、意図して起こした別々のイベントはまとめない、と説明されています。たとえば、最初のクリックでフォームを無効にしたときに、2回目のクリックでもう一度送信されないようにするためです。
まとめられても、値を渡して3回更新すると1しか増えない
まとめられるのは描画で、set に渡した更新そのものは、呼んだ順に1つずつ処理されます。同じ state を、値を渡して3回更新する場合と、関数を渡して3回更新する場合を比べました。
import { useState } from 'react';
import { say } from './log';
// 同じ state を3回続けて更新する
export function PlusThree() {
const [number, setNumber] = useState(0);
say(` 描画: number=${number}`);
return (
<>
<button
id="value"
onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}
>
値を渡す
</button>
<button
id="updater"
onClick={() => {
setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);
}}
>
関数を渡す
</button>
</>
);
}
2つのボタンは、この順に続けて押しています。React 19 の結果です。
[値を渡して3回]
描画: number=1
→ 描画 1回
[関数を渡して3回]
描画: number=4
→ 描画 1回
どちらも描画は1回でした。値を渡した方は、3回とも 0 + 1 を渡しているので、1にしかなりません。関数を渡した方は、前の結果に1を足す関数が3回順に呼ばれ、1から4になりました。同じ state を続けて更新する方法を説明した公式ドキュメントには、次の描画の前に同じ state を何度も更新したいときは、前の値から次の値を計算する関数を渡せばよい、と書かれています。
createRoot を使っていれば、setTimeout、Promise、addEventListener の中の更新も1回の描画にまとまります。まとめたくない更新は、flushSync で包みます。ReactDOM.render のままのアプリでは、React 18 にしても、React のイベントハンドラの外の更新はまとまりません。





