JavaScript の配列はミュータブル(mutable, 書き換え可能)なものですが、state に格納する場合はイミュータブル(immutable, 書き換え不能)として扱うべきです。オブジェクトの時と同様に、state に保存された配列を更新する場合は、新しい配列を作成して(または既存の配列をコピーして)、その新しい配列で state をセットする必要があります。

このページで学ぶこと

  • React の state 内にある配列に対し要素の追加、削除、変更を行う方法
  • 配列内にあるオブジェクトを更新する方法
  • Immer を使って配列コピーのためのコードの冗長さを緩和する方法

配列を書き換えずに更新する

JavaScript において、配列とは単なるオブジェクトの一種です。オブジェクトのときと同様にReact の state 内にある配列は、読み取り専用として扱う必要があります。これは、arr[0] = 'bird' のような形で配列内の要素に再代入を行ってはならず、push()pop() のような配列をミューテーション(mutation, 書き換え)するメソッドを使ってもいけないということです。

代わりに、いつでも配列を更新したいときには、新しい配列を state セッタ関数に渡す必要があります。これを実現するために、state から取り出した元の配列から、filter()map() といった書き換えを行わないメソッドを呼び出すことで、新しい配列を作成できます。そして、結果として得られた新しい配列を state にセットすることができます。

以下は、一般的な配列操作の参照表です。React の state 内の配列を扱う際には、左の列にあるメソッドを避けて、右の列にあるメソッドを使用する必要があります :

使わない(配列を書き換える)使う(新しい配列を返す)
追加push, unshiftconcat, [...arr] spread syntax ()
削除pop, shift, splicefilter, slice ()
要素置換splice, arr[i] = ... 代入文map ()
ソートreverse, sort先に配列をコピー ()

また、どちらの列のメソッドも使用できるようにしてくれる Immer を使う方法もあります。

落とし穴

残念ながら、slicesplice は名前が似ているものの、非常に異なるものです。

  • slice は配列や配列の一部をコピーします。
  • splice は(要素の挿入や削除という)配列のミューテーションを行います。

React では、state 内のオブジェクトや配列を書き換えたくないため、slicep なし!)の方をより頻繁に使用します。オブジェクトの更新で、ミューテーションとは何か、それがなぜ state において推奨されないかについて説明されています。

配列に要素を追加

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>
    </>
  );
}

何度か “Delete” ボタンをクリックして動作を確認したら、クリックハンドラを見てみましょう。

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

ここで、artists.filter(a => a.id !== artist.id) というコードは「artist.id と異なる ID を持つ artists のみの配列を作成する」という意味です。言い換えると、各アーティストの “Delete” ボタンは、該当アーティストを配列からフィルタリングして取り除き、結果として得られる配列で再レンダーを要求します。filter は元の配列を書き換えないことに注意してください。

配列の変換

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

この例では、配列に 2 つの円と 1 つの正方形の座標が含まれています。ボタンを押すと、円だけが 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,
        }} />
      ))}
    </>
  );
}

配列内の要素の置換

配列内の一部の要素だけを置き換えたい場合がよくあります。arr[0] = 'bird' のような代入は元の配列を書き換えてしまうので、代わりにここでも map を使用する必要があります。

要素を置き換えるには、map を使って新しい配列を作成します。map の呼び出し内では、第 2 引数として要素のインデックスを受け取ります。これを使用して、元の要素(第 1 引数)を返すか、他のものを返すかを決定します。

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() メソッドを使用すると、配列の「スライス」を切り取ることができます。要素を挿入するには、挿入ポイントの前のスライス、新しい要素、元の配列の残りの部分からなる配列を作成します。

この例では、“Insert” ボタンは常にインデックス 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" で個々の要素に代入したりすることができます。

ただし、配列をコピーしても、その中の既存のアイテムを直接変更することはできません。これは、コピーが浅く (shallow) 行われるためです。新しい配列には、元の配列と同じ要素が含まれます。そのため、コピーされた配列内のオブジェクトを書き換えると、既存の state が書き換えられます。例えば、このようなコードは問題があります。

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 も変更されます。これは state のミューテーションであり、避けるべきです! この問題は、ネストされた JavaScript オブジェクトの更新と同様の方法で解決できます。変更を加えたい個々の要素を、書き換える代わりにコピーするということです。以下で説明します。

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

オブジェクトは、実際には配列の「中に」あるわけではありません。コード中では「中に」あるように見えますが、配列内の各オブジェクトはそれぞれ独立した値であり、配列はそれらを「参照」しています。これが、list[0] のようなネストしたフィールドを変更する際に注意が必要な理由です。他の人のアートワークリストが、配列の同じ要素を指しているかもしれません!

ネストされた state を更新する際には、更新したい箇所からトップレベルまでのコピーを作成する必要があります。どのように行うのか見てみましょう。

この例では、2 つの別々のアートワークリストが同じ初期 state を持っています。これらは独立していることになっているのですが、ミューテーションが起きているため state が誤って共有され、一方のリストでボックスをチェックするともう一方のリストに影響してしまっています。

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 を変更することで、元のアートワークも変更されます。そのアートワークは yourList 内にも存在するため、バグが発生します。このようなバグは理解するのが難しいことがありますが、幸いにも state のミューテーションさえ避けておけば、起こさずに済みます。

ミューテーションをせずに、古い要素を更新された要素と置き換えるには、map を使用できます

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

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

このアプローチでは、既存の state の要素を書き換えないため、バグは修正されています。

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>
  );
}

一般的には、作成したばかりのオブジェクト以外は書き換えてはいけません新しいアートワークを挿入する場合にその新しい要素を書き換えても構いませんが、state にすでに存在するものを扱う場合は、コピーを作成する必要があります。

Immer を使って簡潔な更新ロジックを書く

配列がネストされている場合、書き換えなしで更新を行うコードは冗長になりがちです。オブジェクトのときと同様ですが、

  • 一般的には、state を 2〜3 レベル以上深く更新する必要はないはずです。state オブジェクトが非常に深い場合は、別の構造に再構成してフラットにすることができます。
  • state 構造を変更したくない場合は、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": {}
}

Immer を使用することで artwork.seen = nextSeen のような書き換えができるようになりました

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

これが可能なのは、Immer から渡される特別な draft オブジェクトを書き換えているのであり、元の state は書き換えていないためです。同様に、draft の内容に対して push()pop() などのミューテーション型のメソッドを使用することもできます。

裏側では、Immer は常に、draft に対して行った書き換え操作に基づいて、次の state をゼロから構築します。これにより、state を書き換えてしまう心配をせず、イベントハンドラを非常に簡潔に保つことができます。

まとめ

  • 配列を state に入れることができるが、それを直接変更してはいけない。
  • 配列を変更せず、代わりに新しい版を作成し、state を更新する。
  • [...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>
  );
}