Reactの自動バッチ処理でstateの更新がまとまる条件

​​React​flushSync,Playwright,TypeScript,useState

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 のそれぞれから呼びます。

tsx
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つです。

text
[イベントハンドラ]
  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 で動かした結果です。

text
[イベントハンドラ]
  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 と同じ結果でした。このとき、開発用のビルドでは次の警告が出ました。

text
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回が含まれています。

React 17 と React 19 で、setTimeout から2つの state を更新するボタンを3回押す。React 17 は描画の回数が1回押すごとに2増え、React 19 は1ずつ増える
setTimeout の中での更新。上の React 17 は1回押すごとに描画が2回、下の React 19 は1回

Chromium で試したところ、3回押した後の描画の回数は、React 17 が7回、React 19 が4回でした。

まとめられない書き方

createRoot でも、更新が1回の描画にまとまらない場合があります。React 19 の結果の後半です。

text
[途中で 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回クリックしました。

text
[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回更新する場合を比べました。

tsx
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 の結果です。

text
[値を渡して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 のイベントハンドラの外の更新はまとまりません。

​Posted by 86