Chapter 11無料公開

配列ステートの操作には要注意 (その 2)

Kei Touge
Kei Touge
2022.01.12に更新

シャロー(薄い)コピー

chap.07 ではスプレッド構文によって保たれたイミュータビリティが、前章の Array.prototype.map() メソッドでは保てませんでした。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/map

なぜなら、配列の要素であるオブジェクトの中で入れ子になっているプロパティ(= todos ステート 配列を構成するそれぞれの Todo 型オブジェクトvalue プロパティ)のイミュータビリティを維持するには、シャローコピー(薄いコピー) では不十分だからです。

シャローコピーでは、オブジェクト(や配列)内で入れ子になった要素は原本(コピー元配列)のそれを変わらず参照しています。これを変更すると原本の要素を変更してしまいます。

chap.07 では、もう一段上のレイヤー、つまり配列の要素そのものの追加であったためにシャローコピーによる操作で十分だったのです。

本書では、JavaScript (=TypeScript) におけるシャローコピー・ディープコピーについてはこれ以上詳しく触れません。以下のような素晴らしい解説記事がたくさん存在していますので、これらを参照してください。

https://zenn.dev/luvmini511/articles/722cb85067d4e9

そして、前章までのようなスプレッド構文Array.map() メソッドの使い方は、このシャローコピーにあたります。

原本(コピー元配列)の要素をミューテートから守るためには、完全にコピー(=ディープコピー)された別の配列を用意し、その配列の要素を変更しなければいけません。

ディープコピーでイミュータビリティを確保する

では、前章のコードをディープコピーで書き換えましょう。
いったん todos ステート 配列を同じく Array.map() とスプレッド構文を組み合わせてディープコピーし、そのコピーした配列へあらためて Array.map() を適用します。

src/App.tsx
  const handleOnEdit = (id: number, value: string) => {
    /**
     * ディープコピー:
     * 同じく Array.map() を利用するが、それぞれの要素をスプレッド構文で
     * いったんコピーし、それらのコピー (= Todo 型オブジェクト) を要素とする
     * 新しい配列を再生成する。
     *
     * 以下と同義:
     * const deepCopy = todos.map((todo) => ({
     *   value: todo.value,
     *   id: todo.id,
     * }));
     */
    const deepCopy = todos.map((todo) => ({ ...todo }));

    // ディープコピーされた配列に Array.map() を適用
    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    // todos ステート配列をチェック(あとでコメントアウト)
    console.log('=== Original todos ===');
    todos.map((todo) => console.log(`id: ${todo.id}, value: ${todo.value}`));

    setTodos(newTodos);
  };

この章のソースコード全文:
src/App.tsx
import { useState } from 'react';

type Todo = {
  value: string;
  readonly id: number;
};

export const App = () => {
  const [text, setText] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);

  const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  const handleOnSubmit = () => {
    if (!text) return;

    const newTodo: Todo = {
      value: text,
      id: new Date().getTime(),
    };

    setTodos([newTodo, ...todos]);
    setText('');
  };

  const handleOnEdit = (id: number, value: string) => {
    const deepCopy = todos.map((todo) => ({ ...todo }));

    const newTodos = deepCopy.map((todo) => {
      if (todo.id === id) {
        todo.value = value;
      }
      return todo;
    });

    setTodos(newTodos);
  };

  return (
    <div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          handleOnSubmit();
        }}
      >
        <input type="text" value={text} onChange={(e) => handleOnChange(e)} />
        <input type="submit" value="追加" onSubmit={handleOnSubmit} />
      </form>
      <ul>
        {todos.map((todo) => {
          return (
            <li key={todo.id}>
              <input
                type="text"
                value={todo.value}
                onChange={(e) => handleOnEdit(todo.id, e.target.value)}
              />
            </li>
          );
        })}
      </ul>
    </div>
  );
};