Javascript 內的 array 是可改變的,但當你要將它們儲存在 state 中時,應該將其視為不可改變的,就像 object 。當想更新儲存在 state 中的 array 時,你需要建立一個新的(或建立一個現有 array 的副本),並為新 array 設定 state 。
You will learn
- 如何增加、移除或改變 React state 內的 array 項目
- 如何更新 array 內的 object
- 如何使用 Immer 減少重複的 array 複製
更新 Array 而不 Mutation
在 Javascript 中, array 只是另一種型態的 object ,就像 object ,你應該將 React state 中的 array 視為只能讀取。這代表你不應該在 array 內重新指定項目,像是 arr[0] = 'bird'
;也不應該使用會改變 array 的方法,像是 push()
及 pop()
。
反之,每次你想更新 array 時,會需要傳入一個新的 array 到 state 的 setting 函數中。為此,你可以呼叫不會改變的方法,如 filter()
和 map()
,從 state 內的原始 array 中建立一個新 array ,再將其結果設定為 state 的新 array 。
以下是常用的 array 運算參考表格。在 React state 內部處理 array 時,你會需要避免左邊欄位的方法,並替換成右邊欄位的方法:
避免(改變 array ) | 建議(回傳新 array ) | |
---|---|---|
增加 | push , unshift | concat , [...arr] spread 語法 (example) |
移除 | pop , shift , splice | filter , slice (example) |
更新 | splice , arr[i] = ... 賦值 | map (example) |
排列 | reverse , sort | 先複製 array (example) |
此外,你可以使用 Immer,可讓你使用兩個欄位的方法。
加入一個 Array
push
會改變 array ,這是你不想要的:
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> </> ); }
取而代之,建立一個包含目前項目、與新項目在後方的新 array 。有許多方法可以完成,但最簡單的方法是使用 ...
array spread 語法:
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> </> ); }
Array spread 語法讓你可以在原始的 ...artists
前置入項目:
setArtists([
{ id: nextId++, name: name },
...artists // Put old items at the end
]);
在此方法中, spread 可以完成工作,透過 push()
加入在 array 的後方,與 unshift()
加入在 array 的開頭兩種。在上方的沙盒中試試看!
從 Array 中移除
從 array 中移除項目的最簡單方式是將它篩選掉;換句話說,你會產生一個新 array 且不包含它們。為此,使用 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」按鈕幾秒後,再看它的 click 處理器。
setArtists(
artists.filter(a => a.id !== artist.id)
);
這裡,artists.filter(a => a.id !== artist.id)
意思是「建立一個包含那些 artists
ID 的 array ,與 artist.id
是不同的陣列」。換句話說,每個藝術家的「 Delete 」按鈕會把那些藝術家篩選到 array 外面,並使用該結果的 array 請求一次 re-render 。注意 filter
沒有修改原始的 array 。
轉換一個 Array
如果你想改變 array 內的某些或全部的項目,你可以使用 map()
建立一個新 array 。你傳入 map
的函數可以根據其資料或索引(或兩者),決定要對每個項目做的事情。
在此案例中, array 擁有兩個圓形及一個正方形的座標;按下按鈕時,只有圓形會往下移動 50 像素。這是使用 map()
產生一個新 array 資料所完成的:
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, }} /> ))} </> ); }
更新 Array 中的項目
這特別常見於想在 array 內更新一個或多個項目。像是 arr[0] = 'bird'
的賦值會改變原始 array ,因此使用 map
操作會更好。
為了更新項目,使用 map
建立新 array 。在內部呼叫 map
,你會收到項目索引作為第二個引數,使用它決定是否回傳原始 array (第一個引數),或其他:
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> ); }
在 Array 中插入
有時候,你也許會想將一個項目插入到特定的位置,而且不是在最後或最前面。為此,你可以一起使用 ...
array spread 語法與 slice()
方法完成。 slice()
方法讓你可以取下一個 array 的「切片」。為了插入項目,你會建立一個 array ,並在插入點之前展開該切片,接著是新項目,再來才是原始 array 的剩餘部分。
在此案例中,一個 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> </> ); }
對 Array 進行其他改變
有些東西無法使用 spread 語法或不改變的方法操作,例如 map()
及單獨的 filter()
;例如,你會想倒序或排序一個 array 。 Javascript 的 reverse()
及 sort()
方法會改變原始 array ,你無法直接使用它們。
然而,你可以先複製 array 再改變它們。
例如:
import { useState } from 'react'; let nextId = 3; 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> </> ); }
首先,你使用 spread 語法 [...list]
建立一個原始 array 的副本。目前你有副本,可以使用像是 nextList.reverse()
或 nextList.sort()
的改變方法,或甚至透過 nextList[0] = "something"
指定個別的項目。
然而,儘管複製 array ,你還是無法直接改變其內部的現有項目,這是因為複製是淺的——新 array 會包含原始的相同項目。因此,如果你要在複製的 array 內修改一個 object ,會需要改變目前的 state ;例如,類似的程式碼會是問題。
const nextList = [...list];
nextList[0].seen = true; // Problem: mutates list[0]
setList(nextList);
雖然 nextList
和 list
是兩個不同的 array ,但 nextList[0]
和 list[0]
會指向相同的 object ,因此,改變 nextList[0].seen
也是改變 list[0].seen
。這是一個你該避免的 state mutation !你可以使用和更新巢狀 Javadcript object 相似的方式解決該問題——複製你想改變的個別項目,而非改變它們,以下是方法。
在 Array 中更新 Object
Object 不是真的在 array 的「內部」,它們可能出現在程式「內部」,但每個在 array 中的 object ,是 array 所指的一個分散的值。這是為什麼當你改變巢狀區域像 list[0]
時需要小心,另一個人物的藝術品清單也指向相同的元素 array !
更新巢狀的 state 時,你需要從想更新地方建立一個副本,接著向上延伸到最上層。讓我們看看如何操作。
此案例中,兩件藝術品清單擁有相同的初始 state ,它們應該是獨立的;但由於 mutation,它們意外共享 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; // 問題:改變既有的項目
setMyList(myNextList);
雖然 myNextList
array 本身是新的,但項目它們本身和原始的 myList
array 是相同的,因此改變 artwork.seen
即改變原始的 artwork 項目。該 artwork 項目也在 yourList
中,這會引發錯誤,這種錯誤可能難以想像,但好在只要避免改變 state 就不會出現。
你可以使用 map
將舊項目替換成更新的版本,且沒有 mutation。
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// 使用改變建立一個*新* object
return { ...artwork, seen: nextSeen };
} else {
// 沒有改變
return artwork;
}
}));
這裡的 ...
是 object spread 語法,用來建立一個 object 的副本 。
使用此方法,沒有任何現有的 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) { // 使用改變建立一個*新* object return { ...artwork, seen: nextSeen }; } else { // 沒有改變 return artwork; } })); } function handleToggleYourList(artworkId, nextSeen) { setYourList(yourList.map(artwork => { if (artwork.id === artworkId) { // 使用改變建立一個*新* object return { ...artwork, seen: nextSeen }; } else { // 沒有改變 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> ); }
通常你應該只在剛建立 object 時改變它,如果你插入一個新的藝術品,你可以改變它;但如果是處理一些已經在 state 內的東西,你需要建立一個副本。
使用 Immer 編寫簡潔的更新邏輯
更新巢狀的 array 而沒有 mutation 可能會有一點重複,就像使用 object :
- 通常,你不應該需要更新比兩層還深的 state ,如果 state 中的 object 非常的深層,你可能需要重新解構它們,將它們變的平坦。
- 如果你不想改變你的 state 結構,你可以選擇使用 Immer ,可以更方便地編寫,但使用變異語法,並會為你建立一個副本
以下是使用 Immer 重新編寫 Art Bucket List 的範例:
import { useState } from 'react'; import { useImmer } from 'use-immer'; 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, updateMyList] = useImmer( initialList ); const [yourList, updateYourList] = useImmer( initialList ); function handleToggleMyList(id, nextSeen) { updateMyList(draft => { const artwork = draft.find(a => a.id === id ); artwork.seen = nextSeen; }); } function handleToggleYourList(artworkId, nextSeen) { updateYourList(draft => { const artwork = draft.find(a => a.id === artworkId ); artwork.seen = nextSeen; }); } 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 ,目前像是 artwork.seen = nextSeen
的 mutation 是沒問題的:
updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});
這是因為你沒有改變原始的 state ,但改變 Immer 特別提供的 draft
object ;同樣地,你可以應用改變方法在 draft
內,像是 push()
或 pop()
。
在幕後, Immer 總是根據你在 draft
所做的改變,從頭建構下一個 state 。這讓你的事件處理器非常簡潔,且不用改變 state 。
Recap
- 你可以將 array 放入 state 中,但不可以改變它們
- 取代改變陣列,為它建立一個新版本,並將 state 更新在新版本上
- 你可以使用
[...arr, newItem]
array spread 語法建立 array 的新項目 - 你可以使用
filter()
和map()
,透過被篩選和被轉換的項目建立新陣列 - 你可以使用 Immer 保持程式簡潔
Challenge 1 of 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> ); }