更新 State 內的 Array

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, unshiftconcat, [...arr] spread 語法 (example)
移除pop, shift, splicefilter, slice (example)
更新splice, arr[i] = ... 賦值map (example)
排列reverse, sort先複製 array (example)

此外,你可以使用 Immer,可讓你使用兩個欄位的方法。

Pitfall

不幸地,slicesplice 的名稱很像,但卻非常不一樣:

  • slice 讓你可以複製一個或局部的 array。
  • splice 改變 array (插入或刪除項目)。

在 React 中會更常使用 slice (沒有 p !),因為你不希望在 state 中改變 object 或 array 。更新 object 解釋什麼是 mutation ,與為什麼不推薦用在 state 上。

加入一個 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);

雖然 nextListlist 是兩個不同的 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>
  );
}