【テクニカル・上級編】 flushSyncによるバッチ処理の強制解除 – React実践ガイド

こんにちは。普段からReactのソースコードを覗き見しては、その巧妙なファイバー構造とスケジューリングの妙に感嘆しているフロントエンド・ギークの皆さん。

今回は、React 18で導入された「状態更新のバッチング(Batching)」、そしてその魔改造とも言える禁断のAPI `flushSync` について深掘りしていこう。

「状態が変わったら即座にDOMを同期させたい」「サードパーティのDOM操作ライブラリやテスト環境でタイミングがズレてバグる」——そんな現場の泥臭い絶望を華麗に解決するための、しかし諸刃の剣であるこのAPIの本質を、ブラウザの描画エンジン(レイアウト・ペイント)の挙動も含めて叩き込む。

—

React 18のバッチング機構と「非同期の罠」

まず前提として、近年のReactはデフォルトで非常に賢い。React 18以降、`createRoot`でマウントされたアプリケーションでは、イベントハンドラ内だけでなく、Promiseのthen、setTimeout、ネイティブのイベントリスナーなど、どこから呼ばれた状態更新であっても自動的にバッチ化される。

// React 18のデフォルト挙動
const handleClick = () => {
setCount(c => c + 1); // レンダリングは走らない(キューに入る)
setFlag(f => !f); // これもキューに入る
// ここで一度だけ、効率的に1回の再描画(レコンシエーション)が走る
};

これはパフォーマンスの観点(メモリ効率の最大化と無駄なレイアウトスラッシングの防止)において完璧なアプローチだ。V8エンジンやブラウザのメインスレッドを無駄に占有しないための、Reactチームの執念の結晶と言える。

しかし、この「親切心」が牙を剥く瞬間がある。例えば、以下のようなケースだ。

1. 状態を更新した直後に、そのDOMノードの正確なサイズ(`getBoundingClientRect`など)を取得したい。
2. LeafletやD3.js、古いjQueryプラグインなど、Reactの仮想DOMを一切無視して直接DOMをいじりたがるサードパーティ製ライブラリと同期させなければならない。
3. 自動テスト(Testing Library等)において、状態更新とDOM反映のタイミングがズレてアサーションが落ちる。

通常の`useState`のセッターを呼んだだけでは、状態がDOMに反映されるのはReactの次のレンダリングサイクル、つまりJavaScriptの実行コンテキストがブラウザに制御を返した「後」になる。ここで競合や古い参照の参照(Stale Closure)といった、実務で最もメンタルを削られるバグが産声を上げるわけだ。

—

救世主か、禁忌か:`flushSync`の内部挙動

ここで登場するのが `react-dom` から生やされた `flushSync` だ。

import { flushSync } from ‘react-dom’;

const handleAdvancedSync = () => {
// 1. 強制的にこの瞬間の状態更新をキューから吐き出し、DOMを同期的に更新させる
flushSync(() => {
setCount(c => c + 1);
});

// 2. この行に到達した時点で、すでにDOMは最新の状態に書き換わっている!
console.log(myRef.current.getBoundingClientRect().height);
};

`flushSync` の内部で渡したコールバックを実行すると、Reactは通常のスケジューリング(Concurrent Featuresの恩恵など)を一時的にバイパスし、その場で強制的にファイバーツリーの差分計算(Reconciliation)とDOMのコミット(Commit Phase)を同期的に実行する。

ブラウザのメインスレッドを強制的にブロックし、JavaScriptの実行からレイアウト(Reflow)、ペイント(Repaint)のプロセスをその場で完結させる。まさに「バッチ処理の強制解除」である。

—

実践:サードパーティ製ライブラリとの統合における実用コード

では、実際のモダンなWebアプリケーションで、どのようなユースケースにおいてこれが真価を発揮するのか。グラフ描画やCanvas、あるいは複雑なスクロール位置の復元などの実用的なコンポーネントを想定してコードを見てみよう。

import React, { useState, useRef, useEffect } from ‘react’;
import { flushSync } from ‘react-dom’;

// 擬似的なサードパーティ製DOM操作ライブラリ
class LegacyChartRenderer {
constructor(el: HTMLDivElement) {
this.el = el;
}
updateSize() {
// DOMの実際の高さを基に何らかの重い初期化処理を行うと仮定
const rect = this.el.getBoundingClientRect();
console.log(`[LegacyChart] DOMが更新されました。現在の高さ: ${rect.height}px`);
}
}

export const SyncDashboard: React.FC = () => {
const [isExpanded, setIsExpanded] = useState(false);
const containerRef = useRef(null);
const chartInstance = useRef(null);

useEffect(() => {
if (containerRef.current) {
chartInstance.current = new LegacyChartRenderer(containerRef.current);
}
}, []);

const handleToggle = () => {
// 悪い例:
// setIsExpanded(prev => !prev);
// chartInstance.current?.updateSize(); // ← ここではまだDOMの高さが変わっていない!

// 正しいアプローチ(flushSyncを使用)
flushSync(() => {
setIsExpanded(prev => !prev);
});

// ここに到達した瞬間、ReactはDOMの再描画を完了しているため、
// レガシーなライブラリに正確なDOMの状態を即座に伝えることができる。
if (chartInstance.current) {
chartInstance.current.updateSize();
}
};

return (

現在の状態: {isExpanded ? ‘拡張モード’ : ‘通常モード’}

);
};

このコードでは、`isExpanded` の状態変化に伴うDOMの高さ変更を、`flushSync` によってその場で強制確定させている。これにより、非同期のタイミングズレによる「描画のちらつき(Flicker)」や「レガシーライブラリの計算ミス」を完全に封じ込めることができる。

—

アーキテクチャの観点からの重大な注意点(パフォーマンスの罠)

ここまで読んで「なんだ、便利じゃん!全部 `flushSync` で囲めば非同期バグとはおさらばだな!」と思ったそこのあなた。ちょっと待ってほしい。チーフアーキテクトとして、ここからが最も重要な警鐘を鳴らすフェーズだ。

1. レンダリング負荷と「レイアウトスラッシング(Layout Thrashing)」の誘発

Reactがせっかく最適化のために実装したバッチ処理を自らブチ壊すわけだから、当然パフォーマンスへの代償は小さくない。短時間に何度も `flushSync` を呼び出すようなコードを書けば、ブラウザは「スタイル計算 ➔ レイアウト ➔ ペイント」の重い処理を同期的に何度も強制されることになる。
これはモバイル端末のCPUを焼き、スクロールのフレームレート(Jank)を確実に悪化させる。

2. Concurrent Mode(Concurrent React)との相性の悪さ

React 18の真骨頂である並行レンダリング(優先順位付けによるノンブロッキングな描画)の世界において、`flushSync` は「非常ブレーキ」に等しい。
これを多用すると、Reactが描画の優先順位をコントロールする権利を剥奪することになり、アプリケーション全体のスループットが低下する。どうしても必要な局所的な箇所(エッジケース)にのみ限定して使うべきだ。

3. 副作用の順序の逆転

`flushSync` の内部で状態を更新すると、そのコールバックの実行中にライフサイクルや `useEffect` が同期的に発火する。これにより、通常のReactのデータフローのメンタルモデルが崩れ、デバッグが極めて困難なスパゲッティコードを生み出す温床になる。

—

まとめ:使いどころの美学

Reactにおける `flushSync` は、言うなれば「現代的なオートマ車(React 18の自動バッチング)において、どうしてもクラッチペダルを踏んでマニュアルシフトをねじ込みたい時のための最終兵器」だ。

基本方針としては以下の通り:

  • 普段の開発では絶対に使わない。 通常の `useState` と適切な `useEffect`、あるいはカスタムフックによる状態設計で95%の課題は解決する。
  • DOMの測定値に依存するアニメーション計算、Canvas制御、あるいはサードパーティのDOM直接操作ライブラリとの統合においてのみ、ピンポイントで採用する。

Reactの内部挙動を愛し、フレームワークに頼り切るのではなく、ブラウザの描画パイプラインまで見通した上でこの `flushSync` を手なずけることができれば、あなたの作るWebアプリケーションは、堅牢性とパフォーマンスを高次元で両立した、真にプロフェッショナルなプロダクトになるはずだ。

さて、コードエディタに戻るとしよう。次のリファクタリングが待っている。

コメント

タイトルとURLをコピーしました