はじめに
React 18で導入された**自動バッチング(Automatic Batching)**について、React 17との違いを中心にまとめます。
業務でReact 17 → 18の移行を進める中で調べた内容です。「バッチングって何?」から「awaitを挟むとなぜ中間状態が見えるのか」まで、実際のコードで確認しながら整理しました。
バッチングとは
複数の setState を1回のレンダーにまとめる仕組みです。
function handleClick() {
setCount(c => c + 1); // ┐
setFlag(f => !f); // ┼→ まとめて1回だけレンダー
setName('React'); // ┘
}
3回 setState を呼んでいますが、レンダーは1回だけ。これがバッチングです。
もしバッチングがなければ、setState のたびにレンダーが走り、パフォーマンスが悪化します。
React 17 と React 18 の違い
React 17: イベントハンドラの中だけバッチされる
// ✅ onClick内 → バッチされる(レンダー1回)
<button onClick={() => {
setA(1); // ┐
setB(2); // ┼→ まとめて1回レンダー
setC(3); // ┘
}}>
// ❌ setTimeout内 → バッチされない(レンダー3回)
setTimeout(() => {
setA(1); // → 即レンダー(1回目)
setB(2); // → 即レンダー(2回目)
setC(3); // → 即レンダー(3回目)
}, 1000);
なぜこの差があるのか?
onClick はReactが内部でラップしているので「終わったらまとめてレンダー」と管理できます。
一方 setTimeout はブラウザのタイマー機構から呼ばれるため、Reactが把握できませんでした。
React 18: どこから呼ばれても自動バッチ
createRoot を使うと、発生場所に関わらず全ての setState がバッチされます。
// React 18 では setTimeout の中でもバッチされる ✅
setTimeout(() => {
setA(1); // ┐
setB(2); // ┼→ まとめて1回レンダー
setC(3); // ┘
}, 1000);
バッチング範囲の比較表
| 呼び出し場所 | React 17 | React 18 |
|---|---|---|
| イベントハンドラ内(onClick等) | ✅ バッチされる | ✅ バッチされる |
| useEffect 内 | ✅ バッチされる | ✅ バッチされる |
| setTimeout 内 | ❌ バッチされない | ✅ バッチされる 🆕 |
| Promise.then 内 | ❌ バッチされない | ✅ バッチされる 🆕 |
| addEventListener 内 | ❌ バッチされない | ✅ バッチされる 🆕 |
前提条件: React 18の自動バッチングは createRoot を使った場合のみ有効です。レガシーの ReactDOM.render のままでは従来通りの動作になります。
// Before(React 17)
ReactDOM.render(<App />, document.getElementById('root'));
// After(React 18)← これに変えないと自動バッチングが有効にならない
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
自動バッチングで何が起きるか
中間状態が見えなくなる
React 17では、setTimeout 内の setState はバッチされなかったので、中間状態がレンダーされていました。
// React 17 の動作
setTimeout(() => {
setIsLoading(true); // → レンダー → ローディング表示が見える
setIsLoading(false); // → レンダー → ローディング表示が消える
}, 1000);
React 18ではバッチされるので、中間の true は画面に表示されません。
// React 18 の動作
setTimeout(() => {
setIsLoading(true); // ┐ まとめて1回レンダー
setIsLoading(false); // ┘ → 最終値 false だけが反映される
}, 1000);
// ローディング表示が一瞬も見えない!
実務での影響例
例えばデータ取得時のスケルトン表示:
// React 17: スケルトン表示される
const fetchData = async () => {
setIsFetching(true); // → レンダー → スケルトン表示
const data = await API.get();
setItems(data);
setIsFetching(false); // → レンダー → データ表示
};
この例は await を挟んでいるので、React 18でもスケルトンは表示されます(理由は後述)。
しかし、以下のようなパターンは影響を受けます:
// React 17: fetchの.then内で中間状態が見えた
function loadData() {
fetch('/api/data').then(res => res.json()).then(data => {
setItems(data); // React 17: ここで1回レンダー
setIsFetching(false); // React 17: ここで1回レンダー
});
}
// React 18: .then内もバッチされ、1回のレンダーにまとめられる
await を挟むと自動バッチングされない
React 18では全ての setState がバッチされますが、await を挟むとその前後は別のバッチになります。これはReact 17でも18でも同じ仕組みです。
// awaitなし → バッチされる(レンダー1回)
setState(true); // ┐ まとめて1回レンダー
setState(false); // ┘ → 最終値 false だけが反映される(true は画面に出ない)
// awaitあり → バッチが切れる(レンダー2回)
setState(true); // ← ここでレンダー① → true が画面に反映される
await somePromise; // ← ここでバッチが切れる
setState(false); // ← ここでレンダー② → false が画面に反映される
await に達すると、関数がコールスタックから一時的に抜けます。 このとき:
setState(true)
await somePromise
① 関数がコールスタックから抜ける
② イベントループに制御が戻る
③ React がバッチ処理を実行 → レンダー
④ ブラウザがペイント → true が画面に見える ✅
⑤ Promise 解決 → 関数がコールスタックに戻る(再開)
setState(false)
→ 次のバッチで false がレンダーされる
つまり await の前後は別のバッチ になります。
実行順序の確認
const TestComponent = () => {
const [state, setState] = useState('initial');
const testFunction = async () => {
console.log('Before setState');
setState('loading');
console.log('After setState, before await');
await API();
console.log('After await');
setState('done');
};
console.log('Render:', state);
return <div>{state}</div>;
};
出力順序:
Render: initial ← 初回レンダー
Before setState ← testFunction 開始
After setState, before await ← setState はキューに積まれるだけ(まだレンダーしない)
Render: loading ← await で関数が抜けた → バッチ実行 → レンダー
After await ← Promise 解決 → 関数再開
Render: done ← 次のバッチでレンダー
setState('loading') の直後ではなく、await に到達したタイミングでレンダーが走ることがわかります。
flushSync: バッチを強制的に切る
通常は自動バッチングに任せるべきですが、「即座にDOMを更新したい」場合は flushSync を使います。
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setA(1);
});
// この時点でDOMが更新済み
flushSync(() => {
setB(2);
});
// この時点でDOMが更新済み
}
公式には「これが必要になることは稀」とされています。 多用するとパフォーマンスが低下します。
バッチングの実証コード
バッチングの動作を実際に確認するためのコードです。
const [isLoading, setIsLoading] = useState(false);
const [trigger, setTrigger] = useState(0);
// isLoading の変化を監視
useEffect(() => {
console.log('isLoading:', isLoading);
}, [isLoading]);
// trigger が変わったら同期的に true → false
useEffect(() => {
if (trigger === 0) return;
setIsLoading(true);
setIsLoading(false);
}, [trigger]);
// ボタンクリックで実行
<button onClick={() => setTrigger(t => t + 1)}>テスト</button>
結果: ログが一切出ない。true → false がバッチされ、最終値が初期値と同じ false なので、useEffectが発火しません。
React 18移行時のチェックポイント
自動バッチングが影響するのは、以下の全てを満たす箇所だけです:
-
setTimeout/Promise.then/addEventListener内で - 複数の
setStateを呼んでいて - 中間状態に依存したUIがある
useEffect やイベントハンドラ内はReact 17でもバッチされていたので、影響なしです。
まとめ
| React 17 | React 18 | |
|---|---|---|
| バッチ範囲 | イベントハンドラ内のみ | 全ての場所 |
| setTimeout内 | setState毎にレンダー | まとめて1回 |
| 有効化条件 | - |
createRoot を使う |
| バッチを切る方法 | - | flushSync |
| await の扱い | バッチ対象外だった | await前後で別バッチ |
React 18の自動バッチングは基本的にパフォーマンス改善ですが、「中間状態の表示に依存していたコード」がある場合は動作が変わります。移行時はそこだけ確認すれば大丈夫です。