state 内の配列の更新
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 , unshift | concat , [...arr] spread syntax (例) |
削除 | pop , shift , splice | filter , slice (例) |
要素置換 | splice , arr[i] = ... 代入文 | map (例) |
ソート | reverse , sort | 先に配列をコピー (例) |
また、どちらの列のメソッドも使用できるようにしてくれる Immer を使う方法もあります。
配列に要素を追加
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);
nextList
と list
は異なる 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> ); }