こんにちは。Reactを触り始めて数年、コンポーネントの分割やカスタムフックの設計にも慣れ、「そろそろ中級の壁を突破したいぜ」という頃合いのエンジニアの皆さん、日々の開発お疲れ様です。
プロダクトの規模が大きくなってくると、必ずと言っていいほど「どうしても今すぐ、この瞬間にDOMの更新を完了させたい!」という泥臭い要件にぶち当たりますよね。例えば、複雑なアニメーションの起点を作るとき、サードパーティ製のDOMライブラリ(D3.jsやVanillaなチャートライブラリなど)に最新のDOMサイズを正確に測らせたいとき、あるいは自動スクロールを極限までピタッと制御したいとき。
そんなとき、Reactの標準である「バッチ処理(一括更新)」が、時にもどかしく立ちふさがります。
今回は、Reactの裏側の挙動を紐解きながら、そんな緊急事態をスマートに(あるいは泥臭く確実に)解決する最終兵器、`flushSync`について話をさせてください。
—
そもそもReactの「バッチ処理」って裏で何をやっているのか?
実務の話に入る前に、ReactがDOMをどう扱っているかのおさらいです。ここを理解していないと、`flushSync`を見たときに「魔法の杖」と勘違いしてコードベースを地獄に叩き落とすことになります。
React 18以降、`createRoot`を使ったアプリケーションでは、自動バッチング(Automatic Batching)が標準装備されています。
イベントハンドラ内や非同期処理、果てはシークエンスの中であっても、複数の状態更新(`setState`)が走ると、Reactはそれらをまとめて1回だけ再レンダリングとDOMへの反映を行います。
// 例えば、こんなコードがあったとして…
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const handleClick = () => {
setCount(c => c + 1); // まだDOMには反映されない
setFlag(f => !f); // これもまだ反映されない
// ここでhandleClickを抜けた「直後」に、ReactがまとめてDOMを書き換える
};
ブラウザの視点から言えば、JavaScriptのメインスレッドが実行されている最中に、DOMのツリー構造を何度も書き換えて再レイアウト(Reflow)や再描画(Repaint)を走らせるのは、パフォーマンスの観点から大罪です。だからReactは、「ちょっと待て、全部の変更が出揃ってから一気にDOMに反映してやるぜ」と気を利かせているわけですね。これがバッチ処理です。
なぜバッチ処理を「強制解除」する必要があるのか?
大半のケースでは、このReactの親切心に甘えておけば間違いありません。しかし、現場では「どうしてもこの瞬間にDOMがどうなっているか知りたいんだ!」という特殊なユースケースが存在します。
例えば、「新しい要素を追加した直後に、その要素の正確な高さを取得して、スクロール位置をジャストな位置に調整したい」という要件。
通常の`setState`の後にすぐDOMを参照しようとしても、まだReactのバッチ処理が終わっていないため、参照先のDOMは「更新前の古い状態」のままです。ここで登場するのが、今回主役の `flushSync` です。
—
`flushSync` の基本と実務での使い方
`flushSync` は、Reactの `react-dom` からインポートできるAPIです。こいつの中に状態更新の処理をラップしてやると、Reactに対してこう命令できます。
「おい、余計な最適化はいい!今すぐこの場で状態を更新し、DOMの反映まで一気にやりきれ!」
百聞は一見に如かず。実務でそのまま使える、メッセージログが追加されるたびに最下部に強制スクロールするチャット風コンポーネントのコードを見てみましょう。
import React, { useState, useRef } from ‘react’;
import { flushSync } from ‘react-dom’; // 1. react-domからインポート
export const ChatLog: React.FC = () => {
const [messages, setMessages] = useState
‘こんにちは!’,
‘何かお手伝いできることはありますか?’,
]);
const [inputText, setInputText] = useState(”);
// スクロール対象のコンテナを参照するためのRef
const chatContainerRef = useRef
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!inputText.trim()) return;
// 2. DOMの更新とスクロール操作を同期的に行いたいので flushSync で包む
flushSync(() => {
setMessages((prev) => […prev, inputText]);
setInputText(”); // 入力欄もクリア
});
// — ここに到達した瞬間、ReactはすでにDOMの再構築を完了させています —
if (chatContainerRef.current) {
// 最新のDOM高さを取得して、確実に最下部へスクロールさせる
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
};
return (
{messages.map((msg, index) => (
))}
{/ 入力フォーム /}
);
};
このコードでは、`flushSync` を使うことで、`setMessages` が走った瞬間にDOMの描画が強制されます。そのため、その直後の行(`chatContainerRef.current.scrollTop…`)で、「新しく追加された要素を含んだ正しい高さ」を安全に取得・操作できるというわけです。
—
⚠️ 先輩からの厳しい忠告:flushSync は「劇薬」である
さて、ここからがシニアエンジニアとしての本当の警告です。
`flushSync` は確かに強力で、どうしても解決できないDOM同期の壁をブチ破ってくれますが、フロントエンドのパフォーマンスにおける劇薬です。
以下のリスクを理解せずに乱用すると、チームメンバーから冷ややかな目で見られることになります。気をつけてください。
1. レンダリングの最適化(バッチング)を殺す
Reactがせっかく裏側で「まとめて効率よく処理しよう」としているのに、それを無理やりブチ壊すわけですから、パフォーマンスの低下(意図しない強制再レイアウトの多発)に直結します。特にリストのレンダリングや頻繁に発火するイベント内で使うのは厳禁です。
2. サードパーティライブラリとの統合以外では極力使わない
基本的には、DOMのサイズや位置をどうしても同期的に知る必要がある場合(Canvasの操作、アニメーションのライブラリ、複雑なフォーカス管理など)以外で使うべきではありません。普通のデータフローやUIの状態切り替えであれば、`useEffect` や通常の `useState` で100%解決できます。
—
まとめ
今回は `flushSync` によるバッチ処理の強制解除について解説しました。
- 基本はバッチ処理:Reactのデフォルトの最適化(一括更新)を信頼する。
- 最後の手段:どうしても直後のDOMのレイアウト情報が必要なとき(スクロール制御や要素のサイズ測定など)にのみ `flushSync` を検討する。
- パフォーマンスへの代償:多用するとレンダリングの効率が落ちるため、ピンポイントで最小限に使う。
「Reactっぽくないコードだな」と感じるかもしれませんが、実務の現場では、仕様の隙間を縫うためにこうした泥臭い最終手段が必要になる瞬間が必ず訪れます。その引き出しの一つとして、ぜひ今日の知識を役立ててください。
それでは、また次回の現場でお会いしましょう!

コメント