JavaScriptでは配列は変更可能です。しかし、状態に配列を格納する場合は、不変として扱う必要があります。オブジェクトと同様に、状態に格納されている配列を更新したい場合は、新しい配列を作成(または既存の配列のコピーを作成)し、新しい配列を使用して状態を設定する必要があります。

学習内容

  • Reactの状態にある配列へのアイテムの追加、削除、変更方法
  • 配列内のオブジェクトの更新方法
  • Immerを使用して配列のコピーをより簡潔にする方法

変更を伴わない配列の更新

JavaScriptでは、配列は単なるオブジェクトの一種です。オブジェクトの場合と同様Reactの状態にある配列は読み取り専用として扱う必要があります。 つまり、arr[0] = 'bird' のように配列内のアイテムを再代入したり、push()pop() のように配列を変更するメソッドを使用することは避けるべきです。

代わりに、配列を更新するたびに、状態設定関数に新しい配列を渡す必要があります。そのためには、状態にある元の配列から、filter()map() のような変更を伴わないメソッドを呼び出すことで、新しい配列を作成できます。そして、結果として得られた新しい配列を状態に設定します。

一般的な配列操作のリファレンステーブルを以下に示します。Reactの状態にある配列を扱う場合は、左側の列のメソッドを避け、代わりに右側の列のメソッドを使用する必要があります。

避ける(配列を変更する)推奨(新しい配列を返す)
追加pushunshiftconcat[...arr] スプレッド構文(
削除popshiftsplicefilterslice
置換splicearr[i] = ... 代入map
ソートreversesort最初に配列をコピーする(

あるいは、Immerを使用することもできます。これにより、両方の列のメソッドを使用できます。

落とし穴

残念ながら、slicesplice は名前が似ていますが、まったく異なります。

  • slice は、配列またはその一部をコピーするために使用します。
  • splice は配列を変更します(アイテムの挿入または削除)。

Reactでは、状態にあるオブジェクトや配列を変更したくないため、slicep は不要です!)をはるかに頻繁に使用することになります。オブジェクトの更新では、変更とは何か、そしてそれが状態に推奨されない理由について説明しています。

配列への追加

push() は配列を変更するため、使用すべきではありません。

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

代わりに、既存のアイテムと新しいアイテムを最後に含む新しい配列を作成します。これを行う方法は複数ありますが、最も簡単な方法は、... スプレッド構文を使用することです。

setArtists( // Replace the state
[ // with a new array
...artists, // that contains all the old items
{ id: nextId++, name: name } // and one new item at the end
]
);

これで正しく動作します。

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

スプレッド構文を使用すると、元の...artistsの前に配置することで、アイテムの先頭に追加することもできます。

setArtists([
{ id: nextId++, name: name },
...artists // Put old items at the end
]);

このように、スプレッド構文は、配列の最後に追加するpush()と、配列の先頭に追加するunshift()の両方の役割を果たします。上記のサンドボックスで試してみてください!

配列からの削除

配列からアイテムを削除する最も簡単な方法は、フィルタリングすることです。つまり、そのアイテムを含まない新しい配列を作成します。これを行うには、例えばfilterメソッドを使用します。

import { useState } from 'react';

let initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [artists, setArtists] = useState(
    initialArtists
  );

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

「削除」ボタンを数回クリックして、そのクリックハンドラを確認してください。

setArtists(
artists.filter(a => a.id !== artist.id)
);

ここで、artists.filter(a => a.id !== artist.id)は、「IDがartist.idと異なるartistsで構成される配列を作成する」という意味です。つまり、各アーティストの「削除」ボタンはそのアーティストを配列からフィルタリングし、結果の配列で再レンダリングを要求します。filterは元の配列を変更しないことに注意してください。

配列の変換

配列の一部またはすべてのアイテムを変更したい場合は、新しい配列を作成するためにmap()を使用できます。mapに渡す関数は、データまたはインデックス(または両方)に基づいて、各アイテムに対して何を行うかを決定できます。

この例では、配列は2つの円と正方形の座標を保持しています。ボタンを押すと、円のみを50ピクセル下に移動します。これは、map()を使用して新しいデータ配列を作成することで実現します。

import { useState } from 'react';

let initialShapes = [
  { id: 0, type: 'circle', x: 50, y: 100 },
  { id: 1, type: 'square', x: 150, y: 100 },
  { id: 2, type: 'circle', x: 250, y: 100 },
];

export default function ShapeEditor() {
  const [shapes, setShapes] = useState(
    initialShapes
  );

  function handleClick() {
    const nextShapes = shapes.map(shape => {
      if (shape.type === 'square') {
        // No change
        return shape;
      } else {
        // Return a new circle 50px below
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // Re-render with the new array
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        Move circles down!
      </button>
      {shapes.map(shape => (
        <div
          key={shape.id}
          style={{
          background: 'purple',
          position: 'absolute',
          left: shape.x,
          top: shape.y,
          borderRadius:
            shape.type === 'circle'
              ? '50%' : '',
          width: 20,
          height: 20,
        }} />
      ))}
    </>
  );
}

配列内のアイテムの置換

配列内の1つ以上のアイテムを置換したいことは特に一般的です。arr[0] = 'bird'のような代入は元の配列を変更するため、代わりにmapもこれに使用します。

アイテムを置換するには、mapを使用して新しい配列を作成します。map呼び出し内では、アイテムインデックスが2番目の引数として受け取られます。これを使用して、元のアイテム(最初の引数)を返すか、別のものを返すかを決定します。

import { useState } from 'react';

let initialCounters = [
  0, 0, 0
];

export default function CounterList() {
  const [counters, setCounters] = useState(
    initialCounters
  );

  function handleIncrementClick(index) {
    const nextCounters = counters.map((c, i) => {
      if (i === index) {
        // Increment the clicked counter
        return c + 1;
      } else {
        // The rest haven't changed
        return c;
      }
    });
    setCounters(nextCounters);
  }

  return (
    <ul>
      {counters.map((counter, i) => (
        <li key={i}>
          {counter}
          <button onClick={() => {
            handleIncrementClick(i);
          }}>+1</button>
        </li>
      ))}
    </ul>
  );
}

配列への挿入

場合によっては、先頭でも最後でもない特定の位置にアイテムを挿入したい場合があります。これを行うには、...スプレッド構文とslice()メソッドを組み合わせて使用できます。slice()メソッドを使用すると、配列の「スライス」を切り取ることができます。アイテムを挿入するには、挿入ポイントの前にスライスを展開し、次に新しいアイテム、そして元の配列の残りの部分を展開した配列を作成します。

この例では、「挿入」ボタンは常にインデックス1に挿入します。

import { useState } from 'react';

let nextId = 3;
const initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState(
    initialArtists
  );

  function handleClick() {
    const insertAt = 1; // Could be any index
    const nextArtists = [
      // Items before the insertion point:
      ...artists.slice(0, insertAt),
      // New item:
      { id: nextId++, name: name },
      // Items after the insertion point:
      ...artists.slice(insertAt)
    ];
    setArtists(nextArtists);
    setName('');
  }

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        Insert
      </button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

配列へのその他の変更

スプレッド構文とmap()filter()のような変更しないメソッドだけではできないことがいくつかあります。たとえば、配列を反転またはソートしたい場合があります。JavaScriptのreverse()sort()メソッドは元の配列を変更するため、直接使用することはできません。

ただし、最初に配列をコピーしてから、変更を加えることができます。

例:

import { useState } from 'react';

const initialList = [
  { id: 0, title: 'Big Bellies' },
  { id: 1, title: 'Lunar Landscape' },
  { id: 2, title: 'Terracotta Army' },
];

export default function List() {
  const [list, setList] = useState(initialList);

  function handleClick() {
    const nextList = [...list];
    nextList.reverse();
    setList(nextList);
  }

  return (
    <>
      <button onClick={handleClick}>
        Reverse
      </button>
      <ul>
        {list.map(artwork => (
          <li key={artwork.id}>{artwork.title}</li>
        ))}
      </ul>
    </>
  );
}

ここでは、[...list] スプレッド構文を使用して、まず元の配列のコピーを作成します。コピーが作成されたので、nextList.reverse()nextList.sort() などの変更メソッドを使用したり、nextList[0] = "something" のように個々の要素に代入することもできます。

しかし、配列をコピーしても、その中の既存の要素を直接変更することはできません。これは、コピーが浅いコピーであるためです。新しい配列には、元の配列と同じ要素が含まれます。そのため、コピーされた配列内のオブジェクトを変更すると、既存の状態が変更されます。例えば、このようなコードは問題です。

const nextList = [...list];
nextList[0].seen = true; // Problem: mutates list[0]
setList(nextList);

nextListlist は2つの異なる配列ですが、nextList[0]list[0] は同じオブジェクトを指しています。そのため、nextList[0].seen を変更すると、list[0].seen も変更されます。これは状態の変更であり、避けるべきです!この問題は、ネストされたJavaScriptオブジェクトの更新と同様の方法で解決できます。変更したい個々の要素をコピーすることで、状態の変更を回避します。

配列内のオブジェクトの更新

オブジェクトは、配列の「内部」に実際にあるわけではありません。コード上では「内部」にあるように見えるかもしれませんが、配列内の各オブジェクトは別々の値であり、配列はその値を「指している」だけです。list[0] のようなネストされたフィールドを変更する際には注意が必要です。他の人が作成したアートワークリストが、配列の同じ要素を指している可能性があります!

ネストされた状態を更新する際には、更新したい場所から最上位レベルまで、コピーを作成する必要があります。これがどのように機能するかを見てみましょう。

この例では、2つの別々のアートワークリストが同じ初期状態を持っています。これらは分離されているべきですが、状態の変更によって、意図せず状態が共有され、一方のリストのチェックボックスをチェックすると、もう一方のリストにも影響を与えます。

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    const myNextList = [...myList];
    const artwork = myNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setMyList(myNextList);
  }

  function handleToggleYourList(artworkId, nextSeen) {
    const yourNextList = [...yourList];
    const artwork = yourNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setYourList(yourNextList);
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

問題は、このようなコードにあります。

const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // Problem: mutates an existing item
setMyList(myNextList);

myNextList 配列自体は新しいものですが、要素自体は、元の myList 配列と同じです。artwork.seen を変更すると、元の artwork アイテムが変更されます。その artwork アイテムは yourList にも含まれているため、バグが発生します。このようなバグは考えにくい場合もありますが、状態の変更を避ければ、幸いにも消えます。

map を使用して、古いアイテムを更新されたバージョンに置き換えることができます。

setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// Create a *new* object with changes
return { ...artwork, seen: nextSeen };
} else {
// No changes
return artwork;
}
}));

ここでは、... は、オブジェクトスプレッド構文であり、オブジェクトのコピーを作成するために使用されます。

このアプローチでは、既存の状態アイテムの変更は行われず、バグが修正されます。

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    setMyList(myList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

一般的に、新しく作成したオブジェクトのみを変更する必要があります。新しいアートワークを挿入する場合は変更できますが、既に状態にあるものについては、コピーを作成する必要があります。

Immer を使用して簡潔な更新ロジックを作成する

変更を加えずにネストされた配列を更新するのは、少し反復的になる可能性があります。オブジェクトと同様に

  • 一般的に、状態を2レベル以上深く更新する必要はありません。状態オブジェクトが非常に深い場合は、異なる方法で再構成することで、フラットにすることができます。
  • 状態構造を変更したくない場合は、Immerを使用することをお勧めします。Immerを使用すると、便利ですが変更を加える構文を使用して記述でき、コピーの作成を処理します。

Immerを使用して書き直したArt Bucket Listの例を以下に示します。

{
  "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": {}
}

Immer を使用すると、artwork.seen = nextSeen のような変更は問題ありません。

updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});

これは、元の状態を変更するのではなく、Immer によって提供される特別な draft オブジェクトを変更しているためです。同様に、push()pop() のような変更メソッドを draft の内容に適用できます。

内部的には、Immer は常に、draft に対して行った変更に従って、ゼロから次の状態を構築します。これにより、状態を変更することなく、イベントハンドラーを非常に簡潔に保つことができます。

まとめ

  • 配列を状態に配置できますが、変更することはできません。
  • 配列を変更する代わりに、その新しいバージョンを作成し、状態をそれに更新します。
  • [...arr, newItem] 配列スプレッド構文を使用して、新しいアイテムを含む配列を作成できます。
  • filter()map() を使用して、フィルタリングまたは変換されたアイテムを含む新しい配列を作成できます。
  • Immer を使用してコードを簡潔に保つことができます。

課題 1 4:
ショッピングカート内のアイテムの更新

「+」ボタンを押すと対応する数値が増加するように、handleIncreaseClick のロジックを埋めてください。

import { useState } from 'react';

const initialProducts = [{
  id: 0,
  name: 'Baklava',
  count: 1,
}, {
  id: 1,
  name: 'Cheese',
  count: 5,
}, {
  id: 2,
  name: 'Spaghetti',
  count: 2,
}];

export default function ShoppingCart() {
  const [
    products,
    setProducts
  ] = useState(initialProducts)

  function handleIncreaseClick(productId) {

  }

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name}
          {' '}
          (<b>{product.count}</b>)
          <button onClick={() => {
            handleIncreaseClick(product.id);
          }}>
            +
          </button>
        </li>
      ))}
    </ul>
  );
}