😀

ReactのuseStateにおけるプリミティブ型とオブジェクト(参照型)の挙動の理解

2023/12/22に公開

本記事の内容

数値(プリミティブ型)と配列(オブジェクト)に対するuseStateにおける処理の違いをReact公式にあるような典型的なサンプルコードに触れながら、その動作の背景にあるJavascritpのデータ型であるプリミティブ型とオブジェクト(参照型)の関係性の理解を目指します。

useStateで期待通りの状態更新を行う方法については公式含めたくさんの記事がありますので、どちらかというとReactのというよりはJavascriptの勉強という内容かもしれません。

間違いや補足等ありましたらご指摘いただけますと幸いです。

状態更新において直感的に反するコード

ReactのusetStateを使った状態更新について調べていると、Reactの公式に限らず以下のようなサンプルコードに出会うかと思います。

React初心者からするとボタンをクリックするたびに初期値0から値が+3ずつ増えそうなコードであるが、実際は1ずつしか増えません。

import { useState } from 'react';

export const Counter = () {
  const [number, setNumber] = useState(0);
  
  const countUp = () => {
    setNumber(number + 1);
    setNumber(number + 1);
    setNumber(number + 1);
  }

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => countUp()}>+3</button>
    </>
  )
}

この挙動について理解するために、まず以下を試してみました。
とりあえずsetNumberを無視して直接numberを変更しようとしてみます。

const countUp = () => {
    number = number + 1;
  }

実行するとAssignment to constant variable.というエラーが生じる。
どうやらnubmerは定数として処理されているようです。
(const [number, setNumber]で宣言されているから当たり前と言えば当たり前ですが)

numberが定数であるならば、以下のようにnumberを0から1に更新するという同じ処理を3回行っているということです。

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

このような状態(この例ではnumber)の扱いをReact公式では「スナップショット」という表現しています。State as a Snapshot
(スナップショットと表現されることで、React初学者の理解が進むかどうかは分かりませんが)

期待通りに動作させるためにはupdater functionを渡す

setNumber((n) => n + 1);
setNumber((n) => n + 1);
setNumber((n) => n + 1);

Reactの公式によるとsetNumber等のセッター(state setter)に渡す関数をupdater functionと呼ぶようです。

Here, n => n + 1 is called an updater function. When you pass it to a state setter:

ちなみにn => n + 1の部分はアロー関数式における{}returnが省略された形です。
省略しないで書くと、以下のようになります。

setNumber(n => {
  return n + 1;
});

補足

setNumber(n => n + 1);ではなくてsetNumber(number => number + 1);と書いても同じように動作しますが、const [number, setNumber] = useState(0);で定義されるnumberと異なるスコープの変数であることを明示するためにnprev_number等を使用した方が良いかと思います。

また、処理の内容を関数として切り出して以下のように書くこともできます。

const countUp = () => {
  const plusOne = (x) => {
    return x + 1;
  };
  setNumber((n) => plusOne(n));
  setNumber((n) => plusOne(n));
  setNumber((n) => plusOne(n));
}

ちなみに、趣旨とは異なるかと思いますが、こういう風に書いても同じ動作をします。

  const countUp = () => {
    let n = number;
    n = n + 1;
    setNumber(n);
    n = n + 1;
    setNumber(n);
    n = n + 1;
    setNumber(n);

参考までに文字列の更新の場合を試してみました。
数値の場合と同じ理由で直感と異なる挙動をします。
説明は割愛します。

配列での状態更新は数値の場合とは異なる

先ほどは数値における状態更新でしたが、ここでは配列で行なってみます。
まずは上述のコードで試したようなことを行なってみます。

const [arr, setArr] = useState(['ichiro', 'jiro']);

const addElement = () => {
  arr.push('saburo');
  console.log(arr); //["ichiro", "jiro", "saburo"]
  setArr(arr);
};

return (
    <>
      {arr.map((name) => (
        <p key={name}>{name}</p>
      ))}
      <button onClick={() => addElement()}>要素追加</button>
    </>
  );

上述のnumberの場合は、TypeError: Assignment to constant variable.というエラーが生じ、変更できませんでしたが、今回のコードではarr.push('saburo');としてもエラーは生じません。

しかし、ボタンをクリックしても画面上に"saburo"は反映されません。
一方でコンソールで確認すると、クリックするたびに"saburo"が追加されていきます。つまりarrの中身は変わっているのにRectがそれを検知して再描画してくれていません。

反映したい場合は、例えばmdn - スプレッド構文を使って以下のように書く必要があります。

setArr([...arr, "saburo"]);

ちなみに、saburoを3つ追加したい場合は上記と同じようにupdater functioknを使います。

setArr((arr) => [...arr, "saburo"]);
setArr((arr) => [...arr, "saburo"]);
setArr((arr) => [...arr, "saburo"]);

先ほどの数値であるnumberの場合との挙動の違いを理解するためには、数値(プリミティブ型)と配列(オブジェクト)の違いに言及する必要がありますので、以下に説明します。

データ型のおおまかな説明

プリミティブ型とオブジェクト(参照型)についての詳しい説明は別記事(Javascriptにおけるプリミティブ型とオブジェクト(参照型)の違い)にしましたので、ここでは簡単に触れたいと思います。

二つのデータ型に共通する点

プリミティブ型もオブジェクト(参照型)も、変数を宣言した際には、その変数は特定のアドレスに参照するようになる。

二つのデータ型における違い

  • プリミティブ型 : 変数が参照するアドレスには、値が直接格納されている。 (変数 -> データ)
  • オブジェクト(参照型) : 変数が参照するアドレスには、実際のデータの格納場所を示すアドレスが格納されている。(変数 -> アドレス(実データへの参照) -> 実データ)

useStateにおける状態更新の検知は何をもとに判断されているのか?

再びReactのuseStateの挙動に戻ります。
Reactでは、レンダリングの条件として、「stateが更新された場合」とあります。
react.dev - Render and Commitでは以下のように表現されています。この2つ目の項目が今回フォーカスを当てている部分です。

Step 1: Trigger a render
There are two reasons for a component to render:

  1. It’s the component’s initial render.
  2. The component’s (or one of its ancestors’) state has been updated.
    [日本語訳]
  3. aコンポーネント(またはその祖先のいずれか)の state の更新。

上記で見てきたようプリミティブ型である数値と、オブジェクト(参照型)である配列の挙動の違いを見ると、データ型がuseStateにおけるstate更新の検知に関係がありそうにみえますが、useStateは実際は何をベースに「更新された」と判断しているのでしょうか。

結論からいうと、Object.isで比較して、異なっていると判断された場合のみ再レンダリングされます。(逆にいえば、同一(identical)であると判断されるとレンダリングはスキップされます)

react.dev - useState

If the new value you provide is identical to the current state, as determined by an Object.is comparison, React will skip re-rendering the component and its children.

[日本語訳]

新しい値が現在の state と同一の場合、React は最適化のために、コンポーネントとその子コンポーネントの再レンダーをスキップします。state の同一性の比較は、Object.is によって行われます。

mnd - Object.is()

Object.is() は静的メソッドで、 2 つの値が同一値であるかどうかを判定します。

同一値かどうかはデータの型によって異なります。
上述のデータ型で説明したところを絡めて説明すると、

  • プリミティブ型
    現在の値 を setState(新しい値)で更新しようとすると
    Object.is(現在の値、新しい値)で比較

  • オブジェクト(参照型)
    現在の参照アドレスを、setState(新しい参照アドレス)で更新しようとすると、
    Object.is(現在の参照アドレス、新しい参照アドレス)で比較

「値」を比較しているか「アドレス」を比較しているかの違いがあります。

プリミティブの場合は状態更新したければ上記に示したように、新しい値そのものをsetNumberに直接渡すか、新しい値を返す関数をsetNumberに渡せば、Object.isによって異なるstateであると判断してくれます。

setNumber(5);
setNumber((n) => {
  return n + 1;
});

配列のようなオブジェクト(参照型)は、それが指し示す配列の内容は異なっても参照先のアドレスが異なっていないと同じstateだと判断されてしまうので、現在の参照アドレスと異なる参照アドレスを持つ新しい配列を作成し、それをset関数(上記のsetArr等)に渡す必要があります。

新しい配列を作成には、上述のようにmdn - スプレッド構文や、mdn - Array.prototype.concat()を使います。

const newArr = [...arr, "saburo"];
setArr(newArr);
newArr = arr.concat("saburo");
setArr(newArr)

以下公式についてもreact.dev - Updating Arrays in State 引用しておきます。改めてになりますが、重要なのはreturns a new array(新しい配列を返す) という点です。

avoid (mutates the array) prefer (returns a new array)
adding push, unshift concat, [...arr] spread syntax (example)

以上です。
間違い等あればご指摘ください。

Discussion