【テクニカル・上級編】 状態更新のバッチ処理(Automatic Batching) – React実践ガイド

React 18の「Automatic Batching」:レンダリングの魔術と、その向こう側にある深淵

React 18がリリースされた際、多くのエンジニアが「お、便利になったな」と安堵したはずだ。これまでReact 17以前では、非同期コールバックや`setTimeout`の中で連続して状態更新(`setState`)を行うと、その都度レンダリングがトリガーされ、ブラウザのメインスレッドを無駄に占有していた。

しかし、React 18からは「Automatic Batching(自動バッチ処理)」が標準となり、これらはすべて1回のレンダリングサイクルに統合されるようになった。一見すると銀の弾丸に見えるこの機能だが、アーキテクトの視点で見れば、これは単なるパフォーマンスの向上ではなく、「非同期の非決定性」とどう向き合うかという設計思想の転換点である。

今回は、このバッチ処理の裏側にある挙動と、現場で踏み抜くと致命傷になる「落とし穴」について深掘りしていこう。

—

1. バッチ処理の「裏側」を理解する

Reactのレンダリングは、本来コストの高い処理だ。状態更新が呼ばれるたびにコンポーネントツリーを再評価し、仮想DOMを差分検出し、ブラウザのDOMを更新する。

React 18のバッチ処理は、内部的に「現在のタスク(イベントループ)が終了するまでレンダリングを保留する」というスタンスをとっている。つまり、複数の`setState`が発行された際、Reactは「あ、これら全部まとめてから一気にUIを反映させたほうが効率的だよね」と判断し、最後に一度だけコミットフェーズを走らせるわけだ。

しかし、ここでエンジニアが必ず理解しておくべきは、「バッチ処理はあくまでレンダリングの最適化であり、状態の更新ロジックそのものを同期的に完結させるものではない」という事実だ。

—

2. 「flushSync」という名の強制介入

パフォーマンスの最適化は重要だが、時には「この状態更新だけは、即座にDOMに反映してほしい」というケースがある。例えば、サードパーティ製のライブラリがDOMの状態を直接参照している場合や、アニメーションの開始直前などだ。

ここで登場するのが `react-dom` の `flushSync` である。

import { flushSync } from ‘react-dom’;

const handleUpdate = () => {
// 通常ならバッチ処理されるが…
flushSync(() => {
setCount(c => c + 1); // ここで即座にレンダリングが強制される
});

// この時点では、すでにDOMは更新済みである
setFlag(true);
};

`flushSync` を使うことは、Reactの最適化機構に対する「介入」だ。これを使う頻度が高いのであれば、それはコンポーネント設計自体に無理があるか、あるいは状態管理の粒度が適切でないというシグナルだと受け取るべきだろう。

—

3. バッチ処理時代の「非同期の罠」と競合

現場で最も注意すべきは、`async/await` が絡む非同期処理だ。多くのエンジニアが陥る罠として、「`await` を挟むとバッチ処理が切れる」という仕様がある。

const handleClick = async () => {
setCount(c => c + 1); // 1回目の更新

await fetch(‘/api/data’); // ここで一度処理が中断される

setCount(c => c + 1); // 2回目の更新(バッチ処理の対象外!)
};

`await` の直後、Reactは一度制御をメインスレッドに返して他のタスクを処理するため、その後の `setState` は別のレンダリングサイクルとして扱われる。この「バッチ処理が適用される境界」を把握していないと、意図しない再レンダリングの回数増加や、メモリ効率の悪化を招くことになる。

回避策:トランジションの活用

もし、非同期処理の前後で状態を更新したいが、再レンダリング回数は最小限に抑えたいのであれば、React 18の `useTransition` を検討すべきだ。`startTransition` でラップされた更新は「優先度の低い更新」としてマークされ、UIのフリーズを防ぎながら効率的に処理される。

—

4. アーキテクトとして守るべき「守破離」

React 18以降のアプリケーション設計においては、以下の原則を胸に刻んでほしい。

1. 状態更新の「凝集度」を高める:
複数の状態を個別に更新するのではなく、可能な限り `useReducer` や単一のオブジェクト状態にまとめて更新を行うこと。これにより、バッチ処理の恩恵を最大限に受けられる。
2. 副作用(useEffect)の監視:
バッチ処理によって「レンダリング回数が減る」ことは、`useEffect` の発火タイミングにも影響を与える。レンダリング回数に依存した副作用を記述するのは、もはやアンチパターンだ。
3. 不要な依存関係の排除:
`useMemo` や `useCallback` で防げるのはレンダリングの「再評価」までだ。バッチ処理の恩恵を受けるためには、そもそも「無駄なステートを作らない」ことが最大の最適化になる。

結びとして

Automatic Batchingは、我々エンジニアを「レンダリングの細かな制御」という泥沼から救い出してくれた。しかし、その裏側にある非同期処理の挙動を理解せずに甘えていれば、結局は複雑怪奇なバグに足元をすくわれることになる。

「Reactは賢いからよしなにやってくれる」のではなく、「Reactがよしなにやってくれる環境を、我々が設計してやる」。この強気な姿勢こそが、大規模で堅牢なフロントエンドを支える唯一の道だ。

さあ、あなたのコードの `setState` を見直してみよう。それは本当に、今そのタイミングで呼ぶべきものだろうか?

コメント

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