状態には、オブジェクトを含むあらゆる種類のJavaScript値を格納できます。しかし、Reactの状態に直接保持しているオブジェクトを変更すべきではありません。代わりに、オブジェクトを更新したい場合は、新しいオブジェクトを作成(または既存のオブジェクトのコピーを作成)し、そのコピーを使用して状態を設定する必要があります。

学習内容

  • Reactの状態におけるオブジェクトの正しい更新方法
  • 変更せずにネストされたオブジェクトを更新する方法
  • 不変性とは何か、そしてそれを破らない方法
  • Immerを使ってオブジェクトのコピーをより簡潔にする方法

ミューテーションとは何か?

状態には、あらゆる種類のJavaScript値を格納できます。

const [x, setX] = useState(0);

これまで、数値、文字列、ブール値を扱ってきました。これらの種類のJavaScript値は「不変」であり、変更不可能または「読み取り専用」です。値を*置き換える*ために再レンダリングをトリガーできます。

setX(5);

xの状態は0から5に変更されましたが、*数値0自体*は変化していません。JavaScriptでは、数値、文字列、ブール値などの組み込みプリミティブ値に変更を加えることはできません。

今度は状態のオブジェクトを考えてみましょう。

const [position, setPosition] = useState({ x: 0, y: 0 });

技術的には、*オブジェクト自体*の内容を変更することは可能です。これはミューテーションと呼ばれます。

position.x = 5;

しかし、Reactの状態のオブジェクトは技術的には変更可能ですが、数値、ブール値、文字列のようにあたかも変更不可能であるかのように扱うべきです。変更する代わりに、常に置き換えるべきです。

状態を読み取り専用として扱う

言い換えれば、状態に配置する任意のJavaScriptオブジェクトを読み取り専用として扱うべきです。

この例では、現在のポインタの位置を表すオブジェクトを状態に保持しています。赤い点は、プレビュー領域にカーソルをタッチまたは移動したときに移動する必要があります。しかし、点は初期位置にとどまります。

import { useState } from 'react';

export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        position.x = e.clientX;
        position.y = e.clientY;
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

問題は、このコードの部分にあります。

onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}

このコードは、前のレンダリングからのpositionに割り当てられたオブジェクトを変更します。しかし、状態設定関数を使用せずにオブジェクトが変更されたことをReactは認識しません。そのため、Reactはそれに対応して何も行いません。それは、食事が終わった後に注文を変更しようとするようなものです。状態の変更は場合によっては機能しますが、推奨しません。レンダリングでアクセスできる状態の値を読み取り専用として扱うべきです。

この場合、実際に再レンダリングをトリガーするには新しいオブジェクトを作成して状態設定関数に渡します。

onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}

setPositionを使って、Reactに

  • positionをこの新しいオブジェクトに置き換えるように指示しています。
  • そして、このコンポーネントを再度レンダリングします。

プレビュー領域にタッチまたはカーソルを合わせたときに、赤い点がポインタをたどるようになりました。

import { useState } from 'react';

export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        setPosition({
          x: e.clientX,
          y: e.clientY
        });
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

詳細

ローカルミューテーションは問題ない

このようなコードは、状態にある既存のオブジェクトを変更するため問題です。

position.x = e.clientX;
position.y = e.clientY;

しかし、このようなコードは、新しく作成したオブジェクトを更新しているため、全く問題ありません

const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);

実際、これは次のように書くことと完全に同等です。

setPosition({
x: e.clientX,
y: e.clientY
});

更新は、状態に既に存在する既存のオブジェクトを変更する場合にのみ問題になります。新しく作成したオブジェクトを更新しても問題ありません。なぜなら、まだ他のコードがそれを参照していないからです。変更することで、それに依存するものが誤って影響を受けることはありません。これは「ローカル更新」と呼ばれます。レンダリング中にローカル更新を行うことさえできます非常に便利で、全く問題ありません!

スプレッド構文を使用したオブジェクトのコピー

前の例では、positionオブジェクトは常に現在のカーソル位置から新しく作成されます。しかし、多くの場合、新しく作成するオブジェクトの一部として既存のデータを含めることを望むでしょう。たとえば、フォームの1つのフィールドだけを更新したいが、他のすべてのフィールドの以前の値は保持したい場合があります。

これらの入力フィールドは機能しません。onChangeハンドラが状態を更新するからです。

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    person.firstName = e.target.value;
  }

  function handleLastNameChange(e) {
    person.lastName = e.target.value;
  }

  function handleEmailChange(e) {
    person.email = e.target.value;
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

たとえば、この行は以前のレンダリングからの状態を更新します。

person.firstName = e.target.value;

目的の動作を得るための信頼できる方法は、新しいオブジェクトを作成し、それをsetPersonに渡すことです。しかしここでは、既存のデータもコピーする必要があります。なぜなら、フィールドの1つだけが変更されたからです。

setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});

...オブジェクトスプレッド構文を使用すれば、すべてのプロパティを個別にコピーする必要はありません。

setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});

これでフォームが機能するようになりました!

各入力フィールドごとに個別の状態変数を宣言していないことに注意してください。大きなフォームの場合、すべてのデータをオブジェクトにまとめて保持することは非常に便利です—正しく更新する限りは!

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    setPerson({
      ...person,
      firstName: e.target.value
    });
  }

  function handleLastNameChange(e) {
    setPerson({
      ...person,
      lastName: e.target.value
    });
  }

  function handleEmailChange(e) {
    setPerson({
      ...person,
      email: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

...スプレッド構文は「シャロー」であることに注意してください。つまり、1レベルしかコピーしません。これは高速ですが、ネストされたプロパティを更新する場合は、複数回使用する必要があることを意味します。

詳細

複数のフィールドに対する単一のイベントハンドラの使用

オブジェクト定義内で[]中括弧を使用して、動的な名前を持つプロパティを指定することもできます。これが同じ例ですが、3つの異なるイベントハンドラではなく、単一のイベントハンドラを使用しています。

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleChange(e) {
    setPerson({
      ...person,
      [e.target.name]: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          name="firstName"
          value={person.firstName}
          onChange={handleChange}
        />
      </label>
      <label>
        Last name:
        <input
          name="lastName"
          value={person.lastName}
          onChange={handleChange}
        />
      </label>
      <label>
        Email:
        <input
          name="email"
          value={person.email}
          onChange={handleChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

ここで、e.target.nameは、<input> DOM要素に指定されたnameプロパティを参照します。

ネストされたオブジェクトの更新

このようなネストされたオブジェクト構造を考えてみましょう。

const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});

person.artwork.cityを更新したい場合、更新方法が明確です。

person.artwork.city = 'New Delhi';

しかしReactでは、状態を不変として扱います!cityを変更するには、まず新しいartworkオブジェクト(以前のオブジェクトのデータで事前に設定)を作成し、次に新しいartworkを指す新しいpersonオブジェクトを作成する必要があります。

const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

または、単一の関数呼び出しとして記述すると

setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});

これは少し冗長になりますが、多くの場合にうまく機能します。

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    setPerson({
      ...person,
      name: e.target.value
    });
  }

  function handleTitleChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value
      }
    });
  }

  function handleCityChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        city: e.target.value
      }
    });
  }

  function handleImageChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        image: e.target.value
      }
    });
  }

  return (
    <>
      <label>
        Name:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Title:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        City:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Image:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img 
        src={person.artwork.image} 
        alt={person.artwork.title}
      />
    </>
  );
}

詳細

オブジェクトは実際にはネストされていません

このようなオブジェクトは、コードでは「ネストされた」ように見えます。

let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};

しかし、「ネスト」はオブジェクトの動作を説明するのに不正確な方法です。コードが実行されるとき、「ネストされた」オブジェクトというものは存在しません。実際には、2つの異なるオブジェクトを見ています。

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

obj1オブジェクトはobj2の「内部」にはありません。たとえば、obj3obj1を「指す」ことができます。

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

let obj3 = {
name: 'Copycat',
artwork: obj1
};

obj3.artwork.cityを更新すると、obj2.artwork.cityobj1.cityの両方に影響します。これは、obj3.artworkobj2.artwork、およびobj1が同じオブジェクトであるためです。オブジェクトを「ネストされた」ものとして考えると、これは見にくくなります。代わりに、それらはプロパティで互いに「指し示す」個別のオブジェクトです。

Immerを使用した簡潔な更新ロジックの記述

状態が深くネストしている場合は、フラット化を検討することをお勧めします。しかし、状態構造を変更したくない場合は、ネストされたスプレッド演算子のショートカットの方が適しているかもしれません。Immerは、便利な変更可能な構文を使用して記述でき、コピーの作成を処理してくれる人気のライブラリです。Immerを使用すると、オブジェクトを「変更する」という「ルール違反」のように見えるコードを書くことができます。

updatePerson(draft => {
draft.artwork.city = 'Lagos';
});

しかし、通常の変更とは異なり、過去の状態は上書きされません!

詳細

Immerはどのように動作するのでしょうか?

Immerによって提供されるdraftは、Proxyと呼ばれる特殊なタイプのオブジェクトで、操作内容を「記録」します。そのため、自由に好きなだけ変更できます!内部的には、Immerはdraftのどの部分が変更されたかを判断し、編集内容を含むまったく新しいオブジェクトを作成します。

Immerを試してみましょう

  1. npm install use-immerを実行して、Immerを依存関係として追加します。
  2. 次に、import { useState } from 'react'import { useImmer } from 'use-immer'に置き換えます。

上記の例をImmerに変換したものがこちらです。

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

イベントハンドラの簡潔さが大幅に向上していることに注目してください。useStateuseImmerを好きなだけ単一のコンポーネントで自由に組み合わせることができます。特に状態にネストがあり、オブジェクトのコピーによってコードが冗長になる場合、Immerは更新ハンドラを簡潔に保つための優れた方法です。

詳細

いくつかの理由があります。

  • デバッグ:console.logを使用し、状態を変更しない場合、過去のログは最近の状態の変化によって上書きされません。そのため、レンダリング間の状態の変化を明確に確認できます。
  • 最適化:一般的なReactの最適化戦略は、以前のプロップスまたは状態が次のプロップスまたは状態と同じである場合、作業をスキップすることに依存しています。状態を変更しない場合、変更があったかどうかを確認するのは非常に高速です。prevObj === objの場合、内部で何も変更されていないと確信できます。
  • 新機能:開発中の新しいReact機能は、状態がスナップショットとして扱われることに依存しています。過去の状態を変更している場合、新しい機能を使用できなくなる可能性があります。
  • 要件の変更:元に戻す/やり直すの実装、変更履歴の表示、またはフォームを以前の値にリセットできるようにするなど、一部のアプリケーション機能は、何も変更されていない場合に簡単に実行できます。これは、過去の状態のコピーをメモリに保持し、必要に応じて再利用できるためです。変更的なアプローチから始めると、このような機能は後で追加するのが難しくなる可能性があります。
  • よりシンプルな実装:Reactは変更に依存していないため、オブジェクトに対して特別な処理を行う必要はありません。多くの「リアクティブ」なソリューションのように、プロパティを乗っ取ったり、常にProxyでラップしたり、初期化時に他の作業を実行する必要はありません。そのため、Reactでは、サイズに関係なく、任意のオブジェクトを状態に配置できます。パフォーマンスや正確性の問題もありません。

実際には、Reactで状態を変更しても「問題なく動作する」ことがよくありますが、このアプローチを念頭に置いて開発された新しいReact機能を使用できるようにするため、変更しないことを強くお勧めします。将来の貢献者、そしておそらく将来のあなた自身も感謝するでしょう!

要約

  • Reactでは、すべての状態を不変として扱います。
  • オブジェクトを状態に保存する場合、それらを変更してもレンダリングはトリガーされず、以前のレンダリングの「スナップショット」の状態が変更されます。
  • オブジェクトを変更する代わりに、その新しいバージョンを作成し、状態に設定して再レンダリングをトリガーします。
  • {...obj, something: 'newValue'}オブジェクトスプレッド構文を使用して、オブジェクトのコピーを作成できます。
  • スプレッド構文は浅いコピーです。1レベルしかコピーしません。
  • ネストされたオブジェクトを更新するには、更新する場所からすべてをコピーする必要があります。
  • 冗長なコピーコードを減らすために、Immerを使用します。

課題 1 3:
不適切な状態更新を修正する

このフォームにはいくつかのバグがあります。スコアを増やすボタンを数回クリックしてください。増加しないことに注意してください。次に、名前を編集して、スコアが変更内容に「追いついた」ことに注意してください。最後に、姓を編集すると、スコアが完全に消えてしまうことに注意してください。

あなたの仕事は、これらのバグをすべて修正することです。修正する際に、それぞれが発生する理由を説明してください。

import { useState } from 'react';

export default function Scoreboard() {
  const [player, setPlayer] = useState({
    firstName: 'Ranjani',
    lastName: 'Shettar',
    score: 10,
  });

  function handlePlusClick() {
    player.score++;
  }

  function handleFirstNameChange(e) {
    setPlayer({
      ...player,
      firstName: e.target.value,
    });
  }

  function handleLastNameChange(e) {
    setPlayer({
      lastName: e.target.value
    });
  }

  return (
    <>
      <label>
        Score: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        First name:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}