前回、ミニReactを自作して、仮想DOM・diff・useStateの仕組みを一通り体で覚えました。最後に、こんな宿題を残していました。
useStateの罠:「setしたのに値が変わらない」の正体
setCount(count + 1)を3連発しても1しか増えない事件- console.logすると古い値が出る事件(クロージャの話)
- ミニReactに「バッチ更新」を実装して、仕組みから理解する
今回はこの2つの事件を、前回作ったミニReactの上で実際に再現します。そして直していく過程で、多くの説明が省略しがちな事実に突き当たります。
「setCountを3回呼んでも1しか増えないのは、Reactがバッチ処理してるから」
バッチ処理というのは、「更新が起きるたびにいちいち処理せず、ある程度まとめてから最後に1回で片付ける」仕組みのことです。レストランのウェイターが、1品注文されるたびに厨房へ走ったりせず、注文が出揃うまで待ってからまとめて伝えるのと同じ発想です。
……この説明、半分だけ合っています。
- 事件1:
setCount(count + 1)を3連発すると何が起きるか - 事件2:
console.log(count)が古い値を返す理由 - 直し方:関数を渡す(functional updater)
- じゃあ「バッチ処理」は何のため?→ 直した後にもう1つ問題が残っている
- 本物のReactの実装(React 17→18で何が変わったか)
順番に行きます。
事件1:setCountを3回呼んでも1しか増えない
前回作ったミニReactに、こういうコンポーネントを追加します。
function App() {
const [count, setCount] = useState(0);
return h("div", {},
h("p", {}, `count: ${count}`),
h("button", {
onClick: () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
}, "+3のはず")
);
}
素直に考えれば、ボタンを押すとcountは0→3になりそうです。でも実際に動かすと……
[クリック前] count: 0
[クリック後] count: 1
1しか増えません。 これが「事件1」です。
面白いのは、前回作ったミニReactには、まだバッチ処理を一切実装していないということです。setStateが呼ばれるたびに、律儀に即座に再描画しています。それでも1にしかならない。ということは——
「バッチ処理のせいで1しか増えない」という説明は、少なくとも今のミニReactには当てはまらない
じゃあ何が原因なのか、setStateの中身をもう一度見てみます。
function useState(initialValue) {
const i = cursor;
cursor++;
if (states[i] === undefined) states[i] = initialValue;
const setState = (newValue) => {
states[i] = newValue;
rerender(); // 毎回、即座に再描画している
};
return [states[i], setState];
}
1回目のsetCount(count + 1)を追ってみます。
1. onClickの中の count は、このハンドラが作られた瞬間の値 = 0 で固定されている
2. setCount(0 + 1) → setCount(1) が呼ばれる
3. states[0] = 1
4. rerender() が動く → App()が再実行され、新しいcountとハンドラが作られる
(でも、今実行中のonClickの中の count はここでは変わらない)
5. rerender()から戻ってくる
2回目、3回目のsetCount(count + 1)も、同じonClick実行の中で使われているcountは、最初から一度も変わっていません。 だから3回ともsetCount(0 + 1)、つまりsetCount(1)を繰り返しているだけです。3回「1にして」と言っているようなものなので、結果が1になるのは当然でした。
事件2:console.logすると古い値が出る
事件1の途中で、実はもう1つの事件を踏んでいます。
onClick: () => {
setCount(count + 1);
console.log(count); // これは何が出力される?
}
実際に動かすと:
console.log の出力: 0
実際の states 配列: [1]
setCountを呼んだ直後なのに、console.logは古い値(0)を表示します。 しかも内部的にはすでにstates[0]は1に更新済みです。矛盾しているように見えますが、これは正しい挙動です。
なぜこうなるのか:レンダーのたびに、別人のクロージャが作られる
まず、Reactを離れて素のJavaScriptで確認します。
function createGreeter(name) {
return function () {
console.log(`Hello, ${name}`);
};
}
const greetAlice = createGreeter("Alice"); // name="Alice" を覚えて生まれる関数
const greetBob = createGreeter("Bob"); // name="Bob" を覚えて生まれる、別人の関数
greetAlice(); // Hello, Alice
greetAlice(); // 何度呼んでも Hello, Alice(Bobにはならない)
createGreeterを呼ぶたびに、新しいnameと、そのnameを覚えた新しい関数が生まれます。あとからgreetBobを作っても、先に生まれたgreetAliceが覚えているnameが書き換わったりはしません。この「関数が、自分が生まれたときの変数の値を覚えたまま持ち歩く」性質をクロージャと呼びます。
Reactのcountもこれと同じ仕組みです。countは、App()が呼ばれるたびに実行されるconst [count, setCount] = useState(0)という行で、その回の実行に固有の定数として作られます。JavaScriptの関数は、自分が定義されたときの変数を「覚えている」だけで、あとから外部でその変数が(別の実行によって)更新されても、追いかけて読みには行きません。onClickもgreetAliceと同じ、生まれたときのcountを持ち歩く関数の1つです。
図のように、setCountを呼んでrerender()が走っても、それは新しいクロージャを持つ、別のonClick関数を新しく作って、DOM上のボタンに貼り替えているだけです。今まさに実行中の、古いonClick自身が持っているcountの値は、後から書き換えられたりしません。だからconsole.log(count)は、このonClickが生まれたときの値(0)を最後まで表示し続けます。
直し方:値じゃなくて「関数」を渡す
事件1・事件2、どちらも原因は同じでした。countという「値」を直接使っているから、古いままの値を掴み続けてしまう。
これを直すには、setStateに値ではなく、**「現在の値を受け取って、次の値を返す関数」**を渡します。
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
ミニReact側で、この「関数が渡されたら実行する」対応を入れます。
const setState = (next) => {
states[i] = typeof next === "function" ? next(states[i]) : next;
rerender();
};
ポイントは、next(states[i])でクロージャのcountではなく、そのときの最新のstates[i]を直接読みに行っているところです。1回目の呼び出しでstates[0]が1になれば、2回目の呼び出しはその1を受け取って2に、3回目は2を受け取って3にします。実際に動かすと:
[クリック前] count: 0
[クリック後] count: 3
ちゃんと3になりました。 Reactが「setCount(count + 1)じゃなくてsetCount(c => c + 1)を使え」と言っているのは、こういう理由からでした。
じゃあ、「バッチ処理」は何のためにあるのか
値の問題は直りました。でも、まだ1つ問題が残っています。rerenderが呼ばれた回数を数えてみると——
rerender() が呼ばれた回数: 3
正しい値(3)にはなったものの、再描画が3回も走っています。 本当は1回で済むはずの再描画を、setStateのたびに律儀にやっているからです。ボタン1回のクリックで、DOM操作を含む重い処理が3回動くのはもったいない。ここで初めて、本題のバッチ処理の出番です。
バッチ処理を実装する
やりたいことはシンプルです。「同期的(=上から順番に、途中で他の処理を挟まずに実行される)に呼ばれた複数のsetStateを1つにまとめて、最後に1回だけ再描画する」。
これを実現するために、JavaScriptのPromiseという仕組みを借ります。Promise.resolve().then(fn)という書き方は、詳しい仕組みは今回省きますが、「fnの中身を、今実行中の同期処理が全部終わった直後に実行する」という予約だと思ってください。「今すぐ実行」ではなく「一段落ついたら実行」というのがポイントです。
let isBatching = false;
const setState = (next) => {
states[i] = typeof next === "function" ? next(states[i]) : next;
if (!isBatching) {
isBatching = true;
Promise.resolve().then(() => {
isBatching = false;
rerender();
});
}
};
この「一段落ついたら実行される」性質をマイクロタスクと呼びます。1回目のsetStateで「一段落ついたら再描画して」という予約が1回だけ入り、2回目・3回目のsetStateはisBatchingがtrueのままなので何も予約せず、ただstates[i]を更新するだけで終わります。onClickの同期処理が全部終わったあと、予約されていた再描画がまとめて1回だけ走ります。

実際に動かすと:
[クリック後] count: 3
rerender() が呼ばれた回数: 1
値は3のまま、再描画だけ1回に減りました。
「値渡し」×「バッチ処理」の組み合わせを、全部試してみる
ここまでの2つの直し方——関数を渡すこととバッチ処理——は、実はまったく別の問題を解決しています。混同されがちですが、切り分けて実際に4パターン動かしてみると、違いがはっきりします。
| バッチ処理なし | バッチ処理あり | |
|---|---|---|
値を直接渡すsetCount(count+1)
|
count=1 再描画3回 |
count=1 再描画1回 |
関数を渡すsetCount(c=>c+1)
|
count=3 再描画3回 |
count=3 再描画1回 |
(実際にNode.jsで4パターンとも動かして確認した数字です)
これを見ると、はっきり分かります。
- 「値」か「関数」か → 正しい値になるかどうかを決める
- バッチ処理の有無 → **再描画が何回走るか(=速いかどうか)**を決める
この2つは直交した、別々の軸です。 バッチ処理だけ実装しても、value形式でcount+1を渡している限り、答えは相変わらず1のままです。逆に、関数渡しだけ直しても、バッチ処理がなければ答えは正しく3になりますが、無駄に3回描画されます。「setCountを3回呼んでも1しか増えないのはバッチ処理のせい」というよくある説明は、半分しか合っていなかった、というのはこういうことでした。
本物のReactは実際どうなっているか
React 17までとReact 18からで、バッチ処理の範囲が違う
ここまで作ってきたバッチ処理は、実はReact 18が実際にやっていることにかなり近いです。ただし、React 17までとReact 18以降では、バッチ処理が効く範囲が違いました。
-
React 17まで:バッチ処理が効くのは、Reactの
onClickのようなイベントハンドラの中だけでした。setTimeoutやPromise.then、素のDOMイベントリスナーの中では、setStateのたびに毎回律儀に再描画していました -
React 18から:
createRootを使うと、場所を問わず自動的にバッチ処理されるようになりました(Automatic Batching)。setTimeoutの中だろうとfetch().then()の中だろうと関係なく、1回にまとまります
内部的には、今回作ったのと同じようにマイクロタスク(queueMicrotaskに近い仕組み)を使って、同期処理が終わるタイミングをまとめているとされています。
どうしても即座に再描画したいとき:flushSync
まれに「setState直後に、更新されたDOMを読みたい」といったケースのために、ReactDOM.flushSyncというAPIも用意されています。
import { flushSync } from "react-dom";
flushSync(() => {
setCount(c => c + 1);
});
// ここに来た時点で、DOMはもう更新済み
バッチ処理を意図的に1回分だけスキップして、同期的に反映させる書き方です。ただし公式にも「よほどの理由がない限り使うな」と言われている、緊急脱出用のAPIです。
まとめ
-
setCount(count + 1)を複数回呼んでも増えないのは、バッチ処理のせいだけではなく、クロージャが原因。今回のミニReact(バッチ処理なし)でも同じ現象が再現できた -
countという変数は、そのレンダーの実行に固有の定数。あとから外部で状態が更新されても、すでにあるクロージャの中身は書き換わらない - 直し方は、値ではなく関数を
setStateに渡すこと。関数は呼ばれた瞬間の「最新の状態」を直接受け取れる - バッチ処理は、これとは**別の問題(再描画の回数)**を解決する仕組み。値渡し×バッチ処理を実際に4パターン動かすと、「正しい値になるか」と「速いかどうか」が別軸だとわかる
- React 18で、バッチ処理が効く範囲が「イベントハンドラの中だけ」から「どこでも」に広がった
理屈と、実際に4パターン数字を出して比べてみるのとでは、納得感が全然違います。
参考文献
- React公式ブログ「React 18」Automatic Batchingについての解説
- ReactDOM
flushSyncドキュメント
次回
**Fiber ~ なぜReact 16でアーキテクチャが刷新されたのか ~**
第2回で少し触れた通り、Reactが内部で保持している木は、child・sibling・returnという3本のポインタだけで表現されたFiberという構造でした。今回のバッチ処理・スケジューリングの話ともつながる形で、なぜこんな回りくどい表現を選んだのかを見ていきます。
- 差分計算、実は「一気に」やると画面がカクつく問題があった
- Reactが「作業を中断・再開できる」仕組みに作り替えた理由
- 優先度をつけてレンダリングするってどういうこと?
を書きます。
