1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React 18の自動バッチングを完全理解する ― React 17との違いとawaitで切れる理由

1
Last updated at Posted at 2026-04-07

はじめに

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>

結果: ログが一切出ない。truefalse がバッチされ、最終値が初期値と同じ false なので、useEffectが発火しません。

React 18移行時のチェックポイント

自動バッチングが影響するのは、以下の全てを満たす箇所だけです:

  1. setTimeout / Promise.then / addEventListener 内で
  2. 複数の setState を呼んでいて
  3. 中間状態に依存したUIがある

useEffect やイベントハンドラ内はReact 17でもバッチされていたので、影響なしです。

まとめ

React 17 React 18
バッチ範囲 イベントハンドラ内のみ 全ての場所
setTimeout内 setState毎にレンダー まとめて1回
有効化条件 - createRoot を使う
バッチを切る方法 - flushSync
await の扱い バッチ対象外だった await前後で別バッチ

React 18の自動バッチングは基本的にパフォーマンス改善ですが、「中間状態の表示に依存していたコード」がある場合は動作が変わります。移行時はそこだけ確認すれば大丈夫です。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?