ReactのflushSyncで、stateの更新をすぐ画面に反映する方法

​​React​Playwright,TypeScript,useEffect,useRef,useState

React で state を更新しても、その直後の行では、画面(DOM)はまだ古いままです。react-dom の flushSync で更新を包むと、次の行に進む前に画面が更新されます。リストに項目を足してそこまでスクロールしたいときや、印刷の直前に表示を変えたいときに、この違いが結果に出ます。

筆者は検証環境で、flushSync で包む書き方と包まない書き方を並べ、同じ操作をして比べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド)、jsdom と Chromium 141(Playwright 1.56.1 で操作)で動かしました。StrictMode では包んでいません。

setState の直後の行では、画面はまだ古い

ボタンを押すと数を1つ増やし、その直後に、画面に出ている数を ref で読むコンポーネントを作りました。useFlush が true のときだけ、更新を flushSync で包みます。

tsx
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { say } from './log';

// setCount の直後に、画面(DOM)に出ている数を読む
export function CountReader({ useFlush }: { useFlush: boolean }) {
  const [count, setCount] = useState(0);
  const ref = useRef<HTMLSpanElement>(null);
  function handleClick() {
    if (useFlush) {
      flushSync(() => {
        setCount(count + 1);
      });
    } else {
      setCount(count + 1);
    }
    say(`  setCount の直後: state の変数 count=${count} 画面=${ref.current!.textContent}`);
  }
  return (
    <button onClick={handleClick}>
      <span ref={ref}>{count}</span>
    </button>
  );
}

jsdom でボタンを2回押した結果です(say は、結果をログに書くだけの関数です)。

text
--- CountReader(flushSync なし) ---
クリック 1回目
  setCount の直後: state の変数 count=0 画面=0
  少し待った後: 画面=1
クリック 2回目
  setCount の直後: state の変数 count=1 画面=1
  少し待った後: 画面=2
--- CountReader(flushSync あり) ---
クリック 1回目
  setCount の直後: state の変数 count=0 画面=1
  少し待った後: 画面=1
クリック 2回目
  setCount の直後: state の変数 count=1 画面=2
  少し待った後: 画面=2

包まないと、setCount の直後の画面は前の数のままで、少し待つと新しい数になりました。flushSync で包むと、直後の行で画面がもう新しい数になっていました。公式リファレンスで、ブラウザの API などと組み合わせる使い方を説明した箇所にも、flushSync の次の行に来たときには DOM が更新されている、と書かれています。

一方、どちらの書き方でも、変数 count は押す前の値のままでした。flushSync が早めるのは画面の更新で、イベントハンドラの中の変数 count は変わりません。

足した項目までスクロールする

ref で DOM を操作する方法を説明した公式ドキュメントのページに、flushSync が要る例として、項目を足してリストの最後までスクロールするコードがあります。その例を TypeScript に直し、文字の入力欄とスクロールのアニメーション(behavior: 'smooth')を外したものを試しました。項目の文言や id の振り方も変え、lastChild は lastElementChild にしています。

tsx
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';

type Todo = { id: number; text: string };

const initialTodos: Todo[] = [];
for (let i = 0; i < 20; i++) {
  initialTodos.push({ id: i, text: `やること ${i + 1}` });
}

// 項目を足して、リストの最後の項目までスクロールする
export function TodoList({ useFlush }: { useFlush: boolean }) {
  const listRef = useRef<HTMLUListElement>(null);
  const [todos, setTodos] = useState(initialTodos);

  function handleAdd() {
    const newTodo = { id: todos.length, text: `やること ${todos.length + 1}` };
    if (useFlush) {
      flushSync(() => {
        setTodos([...todos, newTodo]);
      });
    } else {
      setTodos([...todos, newTodo]);
    }
    listRef.current!.lastElementChild!.scrollIntoView({ block: 'nearest' });
  }

  return (
    <>
      <button onClick={handleAdd}>追加</button>
      <ul ref={listRef} className="list">
        {todos.map((todo) => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </>
  );
}

リストには、高さ 160px の枠の中でスクロールするように、次の CSS を当てました。

css
.list { height: 160px; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
.list li { height: 32px; line-height: 32px; }

Chromium で「追加」を3回押し、リストの枠の中で見えている一番下の項目を調べました。

text
--- TodoList(flushSync なし) ---
クリック 1回目: 項目数=21 最後の項目=「やること 21」 見えている一番下の項目=「やること 20」
クリック 2回目: 項目数=22 最後の項目=「やること 22」 見えている一番下の項目=「やること 21」
クリック 3回目: 項目数=23 最後の項目=「やること 23」 見えている一番下の項目=「やること 22」
--- TodoList(flushSync あり) ---
クリック 1回目: 項目数=21 最後の項目=「やること 21」 見えている一番下の項目=「やること 21」
クリック 2回目: 項目数=22 最後の項目=「やること 22」 見えている一番下の項目=「やること 22」
クリック 3回目: 項目数=23 最後の項目=「やること 23」 見えている一番下の項目=「やること 23」

包まないと、いつも1つ手前の項目までしかスクロールしませんでした。scrollIntoView を呼んだ時点では、足した項目がまだ DOM になく、それまでの最後の項目にスクロールしたためです。flushSync で包むと、足した項目まで見えました。

flushSyncなしとありのリストで「追加」を交互に3回ずつ押す様子。なしのリストでは足した項目が枠の下に隠れ、ありのリストでは足した項目が一番下に見える
上が flushSync なし、下が flushSync あり。押した直後だけ、画面の一番上の回数と「見えている一番下」の行をピンクにしている(ありでは足した項目も)

リストの下の「見えている一番下」の行は、GIF を撮るためにスクリプトで書き足した表示で、TodoList コンポーネントが描いたものではありません。GIF は見やすさのため、リストの高さや文字の大きさを変えて撮り、マウスのカーソルは画面に重ねて描いています。

印刷の直前に表示を変える

公式リファレンスにある、印刷の直前に表示を変える例は、ブラウザの beforeprint イベントで state を変え、印刷される内容に反映させるものです。この例の表示を日本語にし、印刷ボタンを外して、flushSync のあり・なしを切り替えられるようにしました。

tsx
import { useEffect, useState } from 'react';
import { flushSync } from 'react-dom';

// 印刷の直前(beforeprint)に state を変え、印刷される画面に出す
export function PrintApp({ useFlush }: { useFlush: boolean }) {
  const [isPrinting, setIsPrinting] = useState(false);
  useEffect(() => {
    function handleBeforePrint() {
      if (useFlush) {
        flushSync(() => {
          setIsPrinting(true);
        });
      } else {
        setIsPrinting(true);
      }
    }
    function handleAfterPrint() {
      setIsPrinting(false);
    }
    window.addEventListener('beforeprint', handleBeforePrint);
    window.addEventListener('afterprint', handleAfterPrint);
    return () => {
      window.removeEventListener('beforeprint', handleBeforePrint);
      window.removeEventListener('afterprint', handleAfterPrint);
    };
  }, [useFlush]);
  return <h1>印刷中: {isPrinting ? 'はい' : 'いいえ'}</h1>;
}

印刷ダイアログは開かずに、Playwright の page.pdf() で Chromium に PDF を出力させ、PDF の文字を取り出しました。

text
--- PrintApp(flushSync なし) --- 印刷前の画面=「印刷中: いいえ」 印刷された内容=「印刷中: いいえ」 印刷後の画面=「印刷中: いいえ」
--- PrintApp(flushSync あり) --- 印刷前の画面=「印刷中: いいえ」 印刷された内容=「印刷中: はい」 印刷後の画面=「印刷中: いいえ」

包まないと、PDF には「いいえ」が出ました。公式リファレンスにも、flushSync がないと印刷ダイアログに "no" と出ると書かれています。React が更新を非同期にまとめて行い、state が更新される前に印刷ダイアログが出るためです。flushSync で包むと「はい」が出ました。印刷が終わった後の画面は、どちらも「いいえ」でした。flushSync ありでは、afterprint で「いいえ」に戻っています。window.print() で開く印刷ダイアログでは試していません。

エフェクトの中では効かない

公式リファレンスのトラブルシューティングによると、描画の途中、useEffect や useLayoutEffect の中、クラスコンポーネントのライフサイクルメソッドの中で flushSync を呼んでも、すぐには反映されず、警告だけが出ます。直し方として、イベントハンドラに移すか、queueMicrotask で後に回す方法が紹介されています。useEffect の中で呼ぶ書き方と、queueMicrotask で後に回す書き方を試しました。

tsx
import { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { say } from './log';

// エフェクトの中で flushSync を呼ぶ(公式が「してはいけない」としている書き方)
export function FlushInEffect() {
  const [ready, setReady] = useState(false);
  const ref = useRef<HTMLParagraphElement>(null);
  useEffect(() => {
    flushSync(() => {
      setReady(true);
    });
    say(`  エフェクトで flushSync した直後: 画面=${ref.current!.textContent}`);
  }, []);
  return <p ref={ref}>{ready ? '準備できた' : '準備中'}</p>;
}

// queueMicrotask で、今の描画が終わってから flushSync を呼ぶ
export function FlushInMicrotask() {
  const [ready, setReady] = useState(false);
  const ref = useRef<HTMLParagraphElement>(null);
  useEffect(() => {
    queueMicrotask(() => {
      flushSync(() => {
        setReady(true);
      });
      say(`  マイクロタスクで flushSync した直後: 画面=${ref.current!.textContent}`);
    });
  }, []);
  return <p ref={ref}>{ready ? '準備できた' : '準備中'}</p>;
}

jsdom で描いた結果です。

text
--- FlushInEffect ---
  エフェクトで flushSync した直後: 画面=準備中
  少し待った後: 画面=準備できた
  console.error: flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task.
--- FlushInMicrotask ---
  マイクロタスクで flushSync した直後: 画面=準備できた
  少し待った後: 画面=準備できた
  console.error: なし

useEffect の中で呼ぶと、警告が出て、直後の画面は「準備中」のままでした。state の更新そのものは捨てられず、少し待つと「準備できた」になりました。queueMicrotask で後に回すと、警告は出ず、直後の画面が「準備できた」になりました。

ただし公式リファレンスは、queueMicrotask で後に回す書き方を、ほかに手がないときの最後の手段としています。flushSync 自体が性能を落としうるうえに、この書き方はさらに悪いと書かれています。エフェクトの中で flushSync を呼びたくなったら、まず、その更新をイベントハンドラに移せないかを考えます。

包み方で、描画の回数が変わる

1回のクリックで3つの state を更新し、コンポーネントが何回描画されたかを数えました。mode で、包まない書き方、1つの flushSync にまとめる書き方、更新ごとに flushSync で包む書き方を切り替えます。

tsx
import { useState } from 'react';
import { flushSync } from 'react-dom';

export const renders = { count: 0 };

type Mode = 'plain' | 'oneFlush' | 'eachFlush';

// 1回のクリックで3つの state を更新し、描画の回数を数える
export function ThreeUpdates({ mode }: { mode: Mode }) {
  renders.count++;
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const [c, setC] = useState(0);
  function handleClick() {
    if (mode === 'plain') {
      setA(a + 1);
      setB(b + 1);
      setC(c + 1);
    } else if (mode === 'oneFlush') {
      flushSync(() => {
        setA(a + 1);
        setB(b + 1);
        setC(c + 1);
      });
    } else {
      flushSync(() => setA(a + 1));
      flushSync(() => setB(b + 1));
      flushSync(() => setC(c + 1));
    }
  }
  return <button onClick={handleClick}>{a}-{b}-{c}</button>;
}

jsdom で、それぞれ1回ずつ押した結果です。

text
--- ThreeUpdates mode=plain --- 1回のクリックで描画: 1回 画面=1-1-1
--- ThreeUpdates mode=oneFlush --- 1回のクリックで描画: 1回 画面=1-1-1
--- ThreeUpdates mode=eachFlush --- 1回のクリックで描画: 3回 画面=1-1-1

1つの flushSync の中にまとめた3つの更新は、包まないときと同じく1回の描画で済みました。更新ごとに包むと、3回描画されました。公式リファレンスの注意点は、flushSync が性能を大きく落としうるとしています。包むなら、更新後の DOM を使う処理の前にある更新を、1つの flushSync にまとめます。今回の検証では、そうすると描画は包まないときと同じ1回でした。

flushSync の外で先に呼んだ更新も、一緒に画面に出ることがあります。クリックで setA(1) を flushSync の外で呼んでから、flushSync の中で setB(1) を呼び、直後の画面を読むコンポーネント(Outside)を jsdom で試すと、a と b の両方が 1 になっていました。

text
--- Outside ---
  flushSync の直後: 画面=a=1 b=1

同じ注意点には、中の更新を反映するために、外の更新(クリックで起きた更新など)も先に反映することがある、と書かれています。また、読み込み待ちの Suspense の境界にフォールバックを出させることがある、ともあります。この記事では、Suspense との組み合わせは試していません。

flushSync を使うかどうかの目安

ポイント

flushSync は、state を更新した直後に、更新後の DOM を前提にした処理をしたいときに使います。今回の検証では、足した項目までのスクロールと、印刷の直前の表示がそうでした。flushSync で包むのは、イベントハンドラの中で、更新後の DOM を使う処理の前に置く更新だけにします。

公式リファレンスは、flushSync を使う場面はまれで、React の機能だけを使い、ほかのライブラリと組み合わせていないアプリなら要らないはずだ、としています。一方で、ブラウザの API などと組み合わせるときには役立つ、とも書かれています。今回のスクロールと印刷の例も、ブラウザの API と組み合わせたものでした。更新した直後に DOM を使う処理がないなら、flushSync は使わずに書きます。

​Posted by 86