React では、1回の操作の中で状態(state)を何度更新しても、それらをまとめて一度だけ再レンダリングします。これを自動バッチング(Automatic Batching)と呼びます。無駄な再描画を減らしてくれる仕組みで、通常は意識しなくても恩恵を受けられます。この記事では、自動バッチングとは何か、複数の setState がどのように1回の再レンダリングにまとまるのか、React 17 以前との違い(setTimeout や Promise の中では以前はまとまらなかったこと)、連続して更新するときの落とし穴と対処までを、動くコードとあわせて解説します。対象は React の基本的な状態管理を理解している初心者〜中級者の方です。
目次
自動バッチングとは(複数の更新が1回にまとまる)
バッチングとは、1つのイベントの処理中に setState を複数回呼んでも、React がそれらを即座に個別へ反映するのではなく、いったんまとめてから一度だけ再レンダリングすることをいいます。たとえばボタンを1回クリックしたハンドラーの中で状態を3つ更新したとしても、再レンダリングは3回ではなく1回です。もし更新のたびに再レンダリングしていたら描画が何度も走って無駄が多くなりますが、まとめることでその無駄を防いでいます。
もう一つ大切なのは、状態の更新はすぐには反映されないという点です。setState を呼んでも、その直後に同じ変数を読むとまだ古い値のままです。新しい値になるのは、まとめられた更新をもとに再レンダリングが行われたあとです。この「更新はまとめて、あとから反映される」という感覚をつかむと、React の状態管理でつまずきにくくなります。
基本例:1つのハンドラで複数更新しても再レンダリングは1回
まずは自動バッチングを実際に確かめてみます。次の例では、1回のクリックで count と flag の2つの状態を更新しています。コンポーネントの本体(レンダー中)に console.log を置くと、再レンダリングのたびにログが出るので、何回描画されたかを確認できます。
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
// 再レンダリングのたびに1回出力される
console.log('render:', count, flag);
const handleClick = () => {
// 2つの更新はまとめられ、再レンダリングは1回だけ
setCount((c) => c + 1);
setFlag((f) => !f);
// ここで count を読んでも、まだ更新前の値のまま
console.log('handler:', count); // クリック直後は古い値(例: 0)
};
return (
<button onClick={handleClick}>
count: {count} / flag: {String(flag)}
</button>
);
}
ボタンを1回クリックすると、setCount と setFlag の両方を呼んでいるにもかかわらず、render: のログは1回だけ増えます。2つの更新がまとめられ、再レンダリングが1回に集約されているからです。もしバッチングがなければ更新ごとに描画が走り、ログは2回出るはずです。
あわせて注目したいのが、ハンドラー内の console.log('handler:', count) がまだ古い値を出す点です。setCount を呼んだ直後でも、この count は再レンダリング前の値のままです。新しい値は次の描画で反映されるため、更新した値を同じ関数の中ですぐ使いたいときは注意が必要になります(この点は後半でくわしく扱います)。
React 17 以前との違い(setTimeout や Promise はまとまらなかった)
実は、この「すべての更新がまとまる」挙動は React 18 からのものです。React 17 以前にもバッチングはありましたが、対象はReact のイベントハンドラーの中で行った更新だけでした。setTimeout の中、Promise の then の中、fetch のコールバックの中、あるいは addEventListener で登録したネイティブイベントハンドラーの中などで setState を呼ぶと、それらはまとめられず、更新のたびに再レンダリングが走っていました。
React 18 では、createRoot でアプリをマウントしている場合、これらのケースも含めてすべての更新が自動でまとめられるようになりました。これが「Automatic Batching(自動バッチング)」という呼び名の由来です。次のコードは、React 18 では場所を問わず再レンダリングが1回になる例です。
import { useState } from 'react';
function AsyncBatching() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
console.log('render:', count, flag);
const handleClick = () => {
// setTimeout の中でも、React 18 ではまとめて1回の再レンダリング
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
}, 1000);
};
const handleFetch = () => {
// Promise(fetch)のコールバック内でも同様にまとまる
fetch('/api/example').then(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
});
};
return (
<div>
<button onClick={handleClick}>タイマーで更新</button>
<button onClick={handleFetch}>通信後に更新</button>
</div>
);
}
React 17 以前であれば、上の setTimeout や fetch().then() の中の2つの更新はまとまらず、render: のログは2回出ていました。React 18 では1回に集約されます。バージョンごとの違いを整理すると次のとおりです。なお、この自動バッチングが有効になるのは createRoot を使ってマウントしたときで、古い ReactDOM.render のままでは以前の挙動になります。
| 更新を行う場所 | React 17 以前 | React 18(createRoot) |
|---|---|---|
| イベントハンドラーの中 | まとまる(1回) | まとまる(1回) |
setTimeout の中 | まとまらない(更新ごと) | まとまる(1回) |
Promise / fetch のコールバック内 | まとまらない(更新ごと) | まとまる(1回) |
| ネイティブイベントハンドラー内 | まとまらない(更新ごと) | まとまる(1回) |
連続で更新するときの落とし穴(更新関数形式を使う)
自動バッチングと関わって初心者がよくつまずくのが、前の状態をもとに続けて更新するケースです。たとえば「クリックで2だけ増やしたい」と考えて、次のように setCount(count + 1) を2回書いても、結果は1しか増えません。
const [count, setCount] = useState(0);
const handleClick = () => {
// どちらも「今の count(=0)+ 1」を渡すので、結果は 1 にしかならない
setCount(count + 1); // 0 + 1
setCount(count + 1); // 0 + 1(count はまだ 0 のまま)
};
原因は、前半で見たとおり count がこのハンドラーの実行中ずっと同じ値(この例では 0)のままだからです。1回目も2回目も「0 + 1」を渡しているので、まとめられた結果は 1 になります。狙いどおり2増やすには、直前の値を引数で受け取る更新関数形式を使います。
const [count, setCount] = useState(0);
const handleClick = () => {
// 引数 c には、直前までの更新を反映した最新の値が渡される
setCount((c) => c + 1); // 0 -> 1
setCount((c) => c + 1); // 1 -> 2
// まとめて処理された結果、count は 2 になる
};
更新関数形式 setCount((c) => c + 1) では、引数 c にそれまでの更新を反映した最新の値がキューの順番どおりに渡されます。そのため2回続けて呼べば 0 → 1 → 2 と正しく積み上がります。バッチングされていても正しく動くので、前の状態を使って更新するときは必ず更新関数形式を使うと覚えておくと安心です。逆に、他の状態に依存しない単純な代入(例: 入力値をそのままセット)なら、値をそのまま渡す形でも問題ありません。
どうしても即座に反映したいときは flushSync
自動バッチングは基本的にありがたい仕組みですが、まれに「状態を更新した直後に、更新後の DOM に対して操作したい」という場面があります。たとえば、リストに項目を追加してすぐ末尾までスクロールしたいときなどです。こうしたバッチングを解除して同期的に反映したいケースのために、react-dom には flushSync という関数が用意されています。ただし常用するとバッチングの最適化を打ち消してパフォーマンスを損なうため、あくまで最終手段です。使い方や注意点の詳細は別記事に譲りますが、「即時反映の逃げ道がある」ことだけ覚えておくとよいでしょう。
まとめ
自動バッチング(Automatic Batching)は、1回の操作の中で複数回 setState を呼んでも、React がそれらをまとめて一度だけ再レンダリングする仕組みです。無駄な再描画を減らせる一方で、状態の更新は即座には反映されず、更新した値は次の再レンダリングで新しくなります。React 17 以前はイベントハンドラー内の更新だけがまとめられ、setTimeout や Promise・fetch のコールバック、ネイティブイベント内の更新はまとまりませんでしたが、React 18(createRoot 使用時)からはこれらも含めてすべて自動でまとめられるようになりました。前の値をもとに連続して更新するときは、値をそのまま渡すと期待どおりに積み上がらないため setCount((c) => c + 1) の更新関数形式を使いましょう。どうしても更新を同期的に反映したいときは react-dom の flushSync がありますが、常用は避けるのが基本です。