【実務・中級編】 React 18の自動バッチングと副作用の実行タイミング – React実践ガイド

やあ、現場でコードと格闘している同志諸君。今日もコンポーネントのライフサイクルと睨めっこしていることだろう。

React 18がリリースされてからしばらく経つが、君たちは「自動バッチング(Automatic Batching)」を真に乗りこなせているだろうか? 「なんとなくパフォーマンスが良くなった」で済ませていないか?

実は、この自動バッチングこそが、`useEffect` の実行タイミングや、ひいてはアプリケーションの整合性を左右する重要な鍵を握っている。今日は、ドキュメントの表面をなぞるだけでは決して辿り着けない、Reactのレンダリングサイクルの深淵と、実務でハマりがちな副作用の制御について、チーフアーキテクトの視点から徹底的に解説しよう。

—

1. 自動バッチング:なぜ「一括」で処理されるのか

まず、React 18以前の世界を思い出してほしい。これまでは、Reactのイベントハンドラ内(onClickなど)では状態更新がバッチ化(一括化)されていたが、`setTimeout` や `fetch` のコールバック、あるいはネイティブのイベントリスナ内では、更新のたびに再レンダリングが走っていた。

React 18の「自動バッチング」は、これらあらゆる場所での状態更新を、可能な限り一つの再レンダリングにまとめるというものだ。

なぜこれが重要か?

理由は単純、「ブラウザへの描画コストの削減」だ。
しかし、副作用(useEffect)を扱う我々にとって、これは「useEffectが実行される回数が、君の予想よりも少なくなる可能性がある」ことを意味している。

—

2. レンダリング・コミット・描画、そして副作用のタイミング

ここで、Reactの実行サイクルをブラウザの挙動と合わせて整理しておこう。ここを曖昧にしていると、複雑なUIの実装で必ずボロが出る。

1. Render Phase: 関数コンポーネントが実行され、新しい仮想DOMが計算される。
2. Commit Phase: 実際のDOMに差分が適用される。
3. Browser Paint: ブラウザが画面を更新する。
4. Passive Effects (useEffect): 描画が完了した後に、非同期で実行される。

自動バッチングによって複数の `setState` が一つにまとまると、このサイクル全体が1回しか回らない。つまり、その間に挟まれるはずだった中間状態の `useEffect` はスキップされる。これが「意図した通りに動かない」原因の多くを占めているんだ。

—

3. 実践コード:バッチングとuseEffectの挙動を観測する

理屈をこねるより、コードを見たほうが早いだろう。以下のサンプルは、ボタンをクリックした際に複数の状態を更新し、それがどう `useEffect` に波及するかを確認するものだ。

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

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

// count または flag が変わるたびに実行される副作用
useEffect(() => {
console.log(“— useEffect 実行 —“);
console.log(`状態: count=${count}, flag=${flag}`);

// クリーンアップ関数のタイミングも重要だ
return () => console.log(“— Cleanup 実行 —“);
}, [count, flag]);

const handleClick = () => {
console.log(“— クリックイベント開始 —“);

// React 18では、PromiseやsetTimeoutの中でも自動でバッチングされる
setTimeout(() => {
// 以下の2つの更新は1回の再レンダリングにまとめられる
setCount(c => c + 1);
setFlag(f => !f);

// ここでログを出しても、まだ再レンダリングは行われていない(JavaScriptの実行中だからだ)
console.log(“setTimeout内のsetState完了(まだレンダリング前)”);
}, 100);
};

const handleForceUpdate = () => {
console.log(“— flushSync による強制更新 —“);

// どうしても「即座にDOMに反映し、副作用を動かしたい」場合の最終手段
// ただし、パフォーマンスを犠牲にするため、実務での多用は厳禁だ
flushSync(() => {
setCount(c => c + 1);
});
// この時点で1回目の再レンダリングとuseEffect(の一部)が同期的に終わっている

flushSync(() => {
setFlag(f => !f);
});
};

return (

);
};

export default BatchingDemo;

このコードから学べる「現場の知恵」

1. バッチングの恩恵: `handleClick` 内で2つの `setState` を呼んでいるが、ログを見ればわかる通り、`useEffect` は1回しか走らない。これにより、無駄な計算やAPIリクエストを防げる。
2. 実行のタイミング: `useEffect` はブラウザが画面を描画した「後」に動く。もし、画面が更新される前に何かを計算してDOMをいじりたいなら、`useLayoutEffect` を使う必要がある。しかし、それは「ユーザーがチラつきを感じる」場合のみに限定すべきだ。
3. flushSync の使い所: 基本的には使うな。だが、サードパーティの非Reactライブラリ(例えば古い地図ライブラリやグラフライブラリ)と同期を取る際、Reactの状態更新を即座にDOMへ反映させないと整合性が取れないケースがある。その時だけ、この「劇薬」を取り出すんだ。

—

4. アーキテクトが教える、一歩先の設計判断

自動バッチングが当たり前になった今、中級エンジニアが意識すべきは「状態の依存関係の整理」だ。

よくあるミスは、`useEffect` の中で別の `setState` を呼び、連鎖的にレンダリングを引き起こすこと。これは自動バッチングの恩恵を自ら捨てているようなものだ。

  • Bad: `A` が変わったら `useEffect` で `B` を更新する。
  • Good: `A` と `B` を更新する一つの「アクション関数」を作る。あるいは `useReducer` で一つの状態遷移として定義する。

React 18は、我々に「より宣言的で、よりまとまった状態操作」を求めている。

—

結びに代えて

自動バッチングは、単なる高速化ツールではない。Reactが「UIは状態の射影である」という原則をより純粋に突き詰めた結果だ。

`useEffect` の実行回数が減ったことに違和感を覚えるかもしれない。だが、それはReactが「無駄な中間状態」を排除してくれている証拠だ。我々エンジニアは、その流れに逆らわず、クリーンアップ関数の適切な実装や、依存配列の正確な管理に集中すればいい。

もし君のチームで「なぜかuseEffectが1回しか呼ばれないんです」と悩んでいる後輩がいたら、この記事の内容をドヤ顔で教えてやってくれ。

現場からは以上だ。さあ、ブラウザの向こう側にある最高のユーザー体験を作りに行こう。

コメント

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