0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

setCountを3回呼んでも1しか増えない理由。「バッチ処理のせい」は半分しか合ってなかった

0
Posted at

前回、ミニ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の関数は、自分が定義されたときの変数を「覚えている」だけで、あとから外部でその変数が(別の実行によって)更新されても、追いかけて読みには行きません。onClickgreetAliceと同じ、生まれたときのcountを持ち歩く関数の1つです。

レンダーごとに新しいクロージャが作られる様子。1回目のApp実行で作られたonClickはcount=0を、2回目のApp実行で作られたonClickは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回目のsetStateisBatchingtrueのままなので何も予約せず、ただstates[i]を更新するだけで終わります。onClickの同期処理が全部終わったあと、予約されていた再描画がまとめて1回だけ走ります。
バッチ処理なしではsetStateのたびに再描画が走り合計3回になるが、バッチ処理ありでは3回のsetStateをまとめてから同期処理の終わりに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のようなイベントハンドラの中だけでした。setTimeoutPromise.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が内部で保持している木は、childsiblingreturnという3本のポインタだけで表現されたFiberという構造でした。今回のバッチ処理・スケジューリングの話ともつながる形で、なぜこんな回りくどい表現を選んだのかを見ていきます。

  • 差分計算、実は「一気に」やると画面がカクつく問題があった
  • Reactが「作業を中断・再開できる」仕組みに作り替えた理由
  • 優先度をつけてレンダリングするってどういうこと?

を書きます。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?