一連の state の更新をキューに入れる

state 変数をセットすることで、新しいレンダーがキューに予約されます。しかし、次のレンダーをキューに入れる前に、state の値に対して複数の操作を行いたい場合があります。このためには、React が state の更新をどのようにバッチ処理(batching, 一括処理)するのかについて理解することが役立ちます。

このページで学ぶこと

  • 「バッチ処理」とは何か、React が複数の state 更新を処理する際にどのように使用されるのか
  • 同じ state 変数に対し連続して複数の更新を適用する方法

React は state 更新をまとめて処理する

以下で “+3” ボタンをクリックした場合、setNumber(number + 1) を 3 回呼び出しているので、カウンタが 3 回インクリメントされると思うかもしれません。

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

しかし、前のセクションで説明したように、個々のレンダー内の state 値は固定です。従って setNumber(1) を何度呼び出しても、最初のレンダー内ではイベントハンドラ内の number の値は常に 0 です。

setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

しかしながら、ここにもう 1 つ別の要素が関わってきます。イベントハンドラ内のすべてのコードが実行されるまで、React は state の更新処理を待機します。このため、再レンダーはこれらの setNumber() 呼び出しがすべて終わった後で行われます。

レストランで注文を取るウェイターの話を思い出すかもしれません。ウェイターは最初の料理の注文を聞いた瞬間にキッチンにかけこむわけではありません! 代わりに、客の注文を最後まで聞き、訂正がある場合はそれも聞き取り、さらにはテーブルの他の客からの注文もまとめて受け取るはずです。

レストランで何度も注文をしている客と、それを聞き取っているウェイターである React。客が何度も setState() したとしても、ウェイターは最後のものだけを注文として聞き取って用紙に書き込む。

Illustrated by Rachel Lee Nabors

これにより、複数の state 変数(複数のコンポーネントからの場合も含む)の更新を、再レンダーをあまりに頻繁にトリガすることなしに行うことができます。これは、イベントハンドラおよびその中のコードがすべて完了したまで、UI は更新されないということでもあります。このような動作はバッチ処理(バッチング)とも呼ばれ、これにより React アプリの動作がずっと高速になります。またこれは、変数のうち一部のみが更新された「中途半端な」レンダー結果に混乱させられずに済むということでもあります。

React は、クリックのような意図的に引き起こされるイベントが複数ある場合、それらのバッチ処理を行いません。各クリックは別々に処理されます。React は一般的に安全と判断される場合にのみバッチ処理を行いますので、安心してください。たとえば、最初のボタンクリックでフォームを無効にしたのであれば、2 度目のクリックでフォームが再び送信されてしまわないことが保証されます。

次のレンダー前に同じ state を複数回更新する

一般的なユースケースではありませんが、次のレンダー前に同じ state 変数を複数回更新する場合、setNumber(number + 1) のようにして次の state 値を渡す代わりに、setNumber(n => n + 1) のようにキュー内のひとつ前の state に基づいて次の state を計算する関数を渡すことができます。これは、state の値を単に置き換える代わりに、React に「その state の値に対してこのようにせよ」と伝えるための手段です。

このカウンタをインクリメントしてみてください。

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}

ここで、n => n + 1更新用関数 (updater function) と呼ばれます。これを state のセッタに渡すと:

  1. React はこの関数をキューに入れて、イベントハンドラ内の他のコードがすべて実行された後に処理されるようにします。
  2. 次のレンダー中に、React はキューを処理し、最後に更新された state を返します。
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);

以下に、イベントハンドラを実行するときに、React はこれらのコードをどのように処理するかを示します。

  1. setNumber(n => n + 1): n => n + 1 は関数。React はこれをキューに追加する。
  2. setNumber(n => n + 1): n => n + 1 は関数。React はこれをキューに追加する。
  3. setNumber(n => n + 1): n => n + 1 は関数。React はこれをキューに追加する。

次のレンダー中に useState が呼び出されると、React はこのキューを処理します。前回 number という state の値は 0 だったので、それがひとつ目の更新用関数の引数 n に渡されます。React はひとつ前の更新用関数の返り値を取得し、それを次の更新用関数の n に渡し、というように続いていきます:

キュー内の更新処理n返り値
n => n + 100 + 1 = 1
n => n + 111 + 1 = 2
n => n + 122 + 1 = 3

React は 3 を最終結果として保存し、useState から返します。

以上が、上記の例で “+3” をクリックすると、値が正しく 3 ずつ増加する理由です。

state を置き換えた後に更新するとどうなるか

では、このイベントハンドラはどうでしょうか? 次回のレンダーで number の値はどうなっていると思いますか?

<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
      }}>Increase the number</button>
    </>
  )
}

このイベントハンドラでは、React に次のように指示しています。

  1. setNumber(number + 5): number0 なので、setNumber(0 + 5)。React はキューに 5 に置き換えよ” という命令を追加する。
  2. setNumber(n => n + 1): n => n + 1 は更新用関数。React はその関数をキューに追加する。

次のレンダー時、React は state 更新キューを処理します。

キュー内の更新処理n返り値
5 に置き換えよ”0 (未使用)5
n => n + 155 + 1 = 6

React は 6 を最終結果として保存し、useState から返します。

補足

お気づきかもしれませんが、setState(5) とは、実際には n の使用されない setState(n => 5) と同じように動作します!

state を更新した後に置き換えるとどうなるか

もうひとつ別の例を試してみましょう。次回のレンダーで number は何になると思いますか?

<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Increase the number</button>
    </>
  )
}

このイベントハンドラを実行する際、React は以下の順番でコードを処理します。

  1. setNumber(number + 5): number0 なので setNumber(0 + 5)。React はキューに 5 に置き換えよ” という命令を追加する。
  2. setNumber(n => n + 1): n => n + 1 は更新用関数。React はその関数をキューに追加する。
  3. setNumber(42): React はキューに 42 に置き換えよ” という命令を追加する。

次のレンダー中に、React は state 更新キューを処理します。

キュー内の更新処理n返り値
5 に置き換えよ”0 (未使用)5
n => n + 155 + 1 = 6
42 に置き換えよ”6 (未使用)42

というわけで、React は最終結果として 42 を保存し、useState から返します。

まとめると、setNumber という state セッタに渡すものを、以下のように考えることができます。

  • 更新用関数(例:n => n + 1)の場合、それがキューに追加されます。
  • それ以外の値(例:数値 5)の場合、ここまでのキューの内容を無視する ”5 に置き換えよ” のような命令を追加します。

イベントハンドラが完了した後、React は再レンダーをトリガします。再レンダー中に React はキューを処理します。アップデート関数はレンダー中に実行されるため、更新用関数は純関数である必要があり、結果だけを返すようにする必要があります。その中で state をセットしたり、他の副作用を実行したりしないでください。Strict Mode では、React は各更新用関数を 2 回実行します(ただし 2 つ目の結果は破棄されます)が、これによって間違いを見つけやすくなります。

命名規則

対応する state 変数の頭文字を使って更新用関数の引数の名前を付けることが一般的です。

setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);

もっと長いコードが好きな場合、別の一般的な慣習としては、setEnabled(enabled => !enabled) のように完全な state 変数名を繰り返すか、setEnabled(prevEnabled => !prevEnabled) のようなプレフィックスを使用することがあります。

まとめ

  • state をセットしても既存のレンダーの変数は変更されず、代わりに新しいレンダーが要求される。
  • React は、イベントハンドラが完了してから state の更新を処理する。これをバッチ処理と呼ぶ。
  • 1 つのイベントで複数回 state を更新したい場合 setNumber(n => n + 1) という形の更新用関数を使用できる。

チャレンジ 1/2:
リクエストカウンタの修正

あなたは、ユーザが美術品に対して複数の注文処理を同時並行で行える、アートマーケットアプリの開発をしています。ユーザが “Buy” ボタンを押すたびに、“Pending”(処理中)カウンタが 1 つずつ増えるようにする必要があります。3 秒後に “Pending” カウンタが 1 減り、“Completed” カウンタが 1 増える必要があります。

しかし、“Pending” カウンタは意図した通りに動作していません。“Buy” を押すと、“Pending” が -1 に減少します(あり得ない!)。また、2 回素早くクリックすると、両方のカウンタが予測不可能な挙動を示します。

なぜこれが起こるのでしょうか? 両方のカウンタを修正してください。

import { useState } from 'react';

export default function RequestTracker() {
  const [pending, setPending] = useState(0);
  const [completed, setCompleted] = useState(0);

  async function handleClick() {
    setPending(pending + 1);
    await delay(3000);
    setPending(pending - 1);
    setCompleted(completed + 1);
  }

  return (
    <>
      <h3>
        Pending: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy     
      </button>
    </>
  );
}

function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}