1. ホーム
  2. React

【React】flushSync の使い方|状態更新を同期的に反映して DOM をすぐ更新する

Share

React では複数の状態更新(setState)がまとめて処理され、一度の再レンダリングに集約されます。これを自動バッチングと呼び、通常はパフォーマンス上のメリットになります。ところが「状態を更新した直後に、更新後の DOM に対して操作したい」という場面では、この仕組みが邪魔になることがあります。この記事では、そうしたときに使う flushSync の役割と基本的な使い方、リストに要素を追加してすぐスクロールする・印刷前に DOM を確定させるといった実用例、そして安易に使うべきでない理由までを、動くコードとあわせて解説します。対象は React の基本的な状態管理を理解している初心者〜中級者の方です。

複数の setState はまとめて反映される(自動バッチング)

まず前提として、React の状態更新がどう処理されるかを押さえておきます。イベントハンドラーの中で複数回 setState を呼んでも、React はそれらを即座に個別へ反映するのではなく、いったんまとめてから一度だけ再レンダリングします。この挙動を自動バッチング(automatic batching)と呼びます。React 18 以降は、イベントハンドラーだけでなく setTimeout や Promise、ネイティブイベントの中で行った更新もまとめて処理されるようになりました。

Counter.tsx
import { useState } from 'react';

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

  const handleClick = () => {
    // この2つの更新はまとめられ、再レンダリングは1回だけ
    setCount((c) => c + 1);
    setFlag((f) => !f);
    // ここで count を読んでも、まだ更新前の値のまま
    console.log(count); // 0(クリック直後はまだ古い値)
  };

  return <button onClick={handleClick}>count: {count}</button>;
}

この例で重要なのは、setCount を呼んだ直後に count を参照しても、まだ古い値のままだという点です。状態の更新はすぐに反映されるわけではなく、再レンダリングされて初めて新しい値になります。DOM も同様で、再レンダリングが行われるまでは更新後の内容に変わりません。ほとんどの場合これで問題ありませんが、「更新後の DOM に今すぐ触りたい」ときに困ります。

flushSync とは何か

flushSyncreact-dom が提供する関数で、渡したコールバックの中で行った状態更新を同期的に(その場で)DOM へ反映させるためのものです。通常はバッチングによって後回しにされる再レンダリングを、flushSync の呼び出しが返る前に強制的に完了させます。つまり flushSync を抜けた次の行では、DOM がすでに最新の状態に更新されていることが保証されます。

インポートは react ではなく react-dom から行います。フックではないので、コンポーネントのトップレベルという制約もありません。

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

通常の更新と flushSync を通した更新の違いを整理すると、次のようになります。

書き方DOM が更新されるタイミング
通常の setStateバッチングされ、コールバックを抜けたあとにまとめて反映される
flushSync(() => setState(...))flushSync の呼び出しが返る前に、その場で同期的に反映される

基本の使い方

使い方はシンプルで、DOM をすぐ反映させたい状態更新を flushSync のコールバックの中に書くだけです。次の例では、flushSync の直後に DOM を読み取り、更新が確かに反映されていることを確認しています。

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

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

  const handleClick = () => {
    // コールバック内の更新を、この場で同期的に DOM へ反映する
    flushSync(() => {
      setCount((c) => c + 1);
    });

    // flushSync を抜けた時点で、DOM はすでに更新済み
    // ここで DOM を読み取れば、新しい値が入っている
    const el = document.getElementById('count');
    console.log(el?.textContent); // 更新後の値が表示される

    // flushSync を使わなければ、この時点ではまだ古い値のまま
  };

  return (
    <button id="count" onClick={handleClick}>
      count: {count}
    </button>
  );
}

ポイントは、状態を更新する処理を flushSync のコールバックに入れることです。コールバックの外に setState を書いても同期反映の対象にはなりません。そして「DOM を読み取る・操作する処理」は、flushSync の外(呼び出しの直後)に書きます。こうすることで、更新が終わった DOM に対して安全に触れます。

追加した要素へスクロールする

flushSync がもっとも活きるのが、状態を更新して新しく描画された DOM に対して即座に操作を行いたい場面です。代表例が、リストに項目を追加してすぐ末尾までスクロールするケースです。通常の setState だと、追加処理を書いた直後にはまだ新しい要素が DOM に存在しないため、スクロールしても意図した位置に届きません。

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

function TodoList() {
  const [items, setItems] = useState<string[]>([]);
  const listRef = useRef<HTMLUListElement>(null);

  const addItem = () => {
    const next = `項目 ${items.length + 1}`;

    // flushSync で状態更新を先に確定させ、新しい要素を DOM に描画する
    flushSync(() => {
      setItems((prev) => [...prev, next]);
    });

    // ここではすでに新しい要素が描画済みなので、末尾までスクロールできる
    const list = listRef.current;
    if (list) {
      list.scrollTop = list.scrollHeight;
    }
  };

  return (
    <div>
      <button onClick={addItem}>追加</button>
      <ul ref={listRef} style={{ height: 160, overflowY: 'auto' }}>
        {items.map((item, i) => (
          <li key={i}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

もし flushSync を使わずに setItems の直後で scrollTop を設定すると、その時点では追加した項目がまだ描画されておらず、scrollHeight も増えていないため、末尾まで届かず一段手前で止まってしまいます。flushSync で更新を確定させてからスクロールすることで、追加した要素を確実に画面内へ表示できます。新しく追加した入力欄に focus() を当てたい場合も同じ考え方で、更新を確定させてから ref 経由でフォーカスします。

印刷前に DOM を確定させる

もう一つの典型例が、ブラウザの印刷ダイアログを開く前に表示内容を確定させたいケースです。window.print() は同期的に実行され、その瞬間の DOM をそのまま印刷対象にします。印刷用のレイアウトへ切り替える状態更新を通常どおり行ってから print() を呼ぶと、切り替えが反映される前に印刷が始まり、古い表示のまま出力されてしまうことがあります。

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

function PrintButton() {
  const [printing, setPrinting] = useState(false);

  const handlePrint = () => {
    // 印刷用の表示に切り替え、その反映を待ってから print する
    flushSync(() => {
      setPrinting(true);
    });

    // ここでは printing=true の表示が確定している
    window.print();

    // 印刷後に元の表示へ戻す
    setPrinting(false);
  };

  return (
    <div>
      <button onClick={handlePrint}>印刷</button>
      {printing ? <p>印刷用のレイアウトです</p> : <p>通常の表示です</p>}
    </div>
  );
}

flushSyncprintingtrue にした反映を確定させてから window.print() を呼ぶことで、印刷ダイアログには切り替え後のレイアウトが確実に表示されます。このように「同期的に動くブラウザ API に、更新後の DOM を渡したい」場面は flushSync の出番です。

常用してはいけない理由と代替手段

flushSync は便利ですが、パフォーマンスを損なうため常用してはいけません。React 公式ドキュメントでも、使用は一般的ではなく最終手段とされています。使う前に、まず別の方法で解決できないかを検討するのが基本です。

バッチングを壊してしまう

flushSync はその名のとおり、たまっている再レンダリングを強制的に吐き出させます。これは自動バッチングによる最適化を打ち消す行為であり、本来1回で済むはずの再レンダリングを増やしてしまう可能性があります。ループの中で flushSync を繰り返し呼ぶような使い方は、レイアウト計算が何度も走って目に見えて遅くなるため避けてください。

多くの場合は ref や useEffect で足りる

「更新後に DOM を操作したい」という要件の多くは、flushSync を使わずに実現できます。値の保持や DOM ノードへの参照が目的なら useRef が適しています。また「状態が変わった後に、その結果へ反応して処理したい」なら useEffect(描画前に同期実行したいなら useLayoutEffect)に処理を書くのが本来のやり方です。状態を依存配列に指定しておけば、更新が反映されたあとに副作用が実行されるため、更新後の DOM を安全に扱えます。

判断の目安としては、「更新のあとに毎回自然に走ってよい処理」は useEffect に寄せ、flushSync は「特定のユーザー操作の流れの中で、その場で同期的に DOM を確定させなければ成り立たない処理(印刷前の確定や、追加直後のスクロールなど)」に限定するのがよいでしょう。まずは refuseEffect で書けないかを考え、それでも解決できない同期的な要件が残ったときにだけ flushSync を選ぶ、という順序を意識してください。

まとめ

React の状態更新は自動バッチングによってまとめて反映されるため、setState の直後にはまだ DOM が更新されていません。flushSyncreact-dom から読み込み、コールバック内の状態更新を同期的に DOM へ反映させる関数で、呼び出しを抜けた時点では DOM が最新になっていることが保証されます。追加した要素へすぐスクロールやフォーカスをする、印刷前にレイアウトを確定させる、といった「更新後の DOM に即座に触りたい」場面で役立ちます。一方でバッチングの最適化を打ち消してパフォーマンスを落とすため常用は禁物で、まずは useRefuseEffect / useLayoutEffect で代替できないかを検討し、どうしても同期的な反映が必要なときの最終手段として使うようにしましょう。

参考ページ