iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 14 篇

Day 14 : 不慌不忙,更新 state 裡的陣列

  • 分享至 

  • xImage
  •  

上輩子福份不夠,這輩子碰到奧客要抓住機會累積一下?

今天目標

  1. 把訂單項目整理成陣列
  2. 分清楚新陣列與新物件
  3. 用展開語法新增項目
  4. 用 filter 刪除指定項目
  5. 用 map 修改杯數與規格
  6. 錯誤修正練習

一筆大量訂單,總是會有不同的糖冰與品項組合,比如同樣的紅茶要半糖微冰與全糖去冰各一杯,今天要試著將品項的不同糖冰組合放到訂單清單內。

把訂單項目整理成陣列

對於飲料訂單,可以預期有品項、單價、杯數與規格(糖冰),但在陣列中,還需要一個可以識別的固定編號,才能在修改的時候找到對應的資料,依照這樣的想法建立型別與陣列:

// 型別放元件外面
type DrinkSpec = {
  sweetness: "全糖" | "半糖" | "無糖";
  ice: "正常冰" | "少冰" | "去冰";
};

type OrderItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
  spec: DrinkSpec;
};

// useState 放 DrinkOrder 的最上層。
const [orders, setOrders] = useState<OrderItem[]>([]);

DrinkSpec 是昨天建立的規格,OrderItem 描述一筆訂單項目。

OrderItem[] 表示由這種項目組成的陣列,後面的 [] 則是初始的空清單。

假設今天有兩筆不同規格與數量的品項 A 和 B:

編號 飲料 單價 杯數 規格
A 紅茶 30 元 2 半糖、少冰
B 紅茶 30 元 1 無糖、去冰

orders 裡面的元素代表的是,不同規格與數量的品項,例如表格中的紅茶有 2 個規格組合,A 組合有 2 杯,B 組合有 1 杯,總共有 3 杯。

分清楚新陣列與新物件

昨天已經學過展開語法,對於陣列也可以使用展開語法,但一樣要注意 淺拷貝(shallow copy) 問題。對陣列直接使用 [...orders],得到的會是新的外層陣列,裡面的項目仍可能連到同一個物件。

測試看看:

{
  const original = [
    { id: "a", quantity: 2, spec: { sweetness: "半糖", ice: "少冰" } },
    { id: "b", quantity: 1, spec: { sweetness: "全糖", ice: "正常冰" } },
  ];
  const copied = [...original]; // 淺拷貝

  console.log(Object.is(original, copied));                 // false
  console.log(Object.is(original[0], copied[0]));           // true
  console.log(Object.is(original[0].spec, copied[0].spec)); // true

  copied[0].spec.sweetness = "無糖";
  console.log(original[0].spec.sweetness); // 無糖
}

上面的測試可以看出 original 和 copied 是不同陣列,但它們的第一筆項目仍是同一個物件,裡面的 spec 也相同。因此如果從 copied 修改甜度,original 讀到的也變成無糖。

這跟昨天的巢狀物件是同一件事,只是外面又多了一層陣列,新陣列不代表新項目,更不表示內層規格也已經複製好了。

如果要複製指定的內層項目可以利用 map():

{
  const original = [
    { id: "a", quantity: 2, spec: { sweetness: "半糖", ice: "少冰" } },
    { id: "b", quantity: 1, spec: { sweetness: "全糖", ice: "正常冰" } },
  ];
  const next = original.map((item) =>
    item.id === "a" // 當 id 是 a,建立新的項目放到新陣列內
      ? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
      : item
  );

  console.log(Object.is(original, next));                 // false
  console.log(Object.is(original[0], next[0]));           // false
  console.log(Object.is(original[0].spec, next[0].spec)); // false
  console.log(Object.is(original[1], next[1]));           // true
  console.log(original[0].spec.sweetness, next[0].spec.sweetness); // 半糖 無糖
}

上面這段分別建立了新的 spec、新的第一筆項目,以及新的陣列,第二筆沒有修改,可以繼續共用,但前提是之後也把它當成唯讀資料。

修改 動作
第一筆的 spec 建立新規格,保留少冰、改成無糖
第一筆項目 建立新項目,保留編號與杯數,連到新規格
外層陣列 建立新陣列,放入更新後的第一筆
第二筆項目 沒有修改,沿用原物件

這裡一樣沒有將整份陣列都深拷貝,而是只針對需要修改的項目建立新物件,其他沒修改的共用並唯讀。

React:Updating objects inside arrays

用展開語法新增項目

理解淺拷貝後,接著來更新清單,首先比較各種操作會用到的方法:

需求 動作 避免做
新增 展開陣列,再接上新項目 push()、unshift()
刪除 filter() 留下其他項目 pop()、shift()、splice()
修改 map() 搭配新物件 直接改 item 或指定 orders[i]
清空 setOrders([]) 把原陣列的 length 設成 0

要注意的是 slice() 和 splice() 拼寫很像,但 slice() 會取得一段淺拷貝,splice() 會修改原陣列。
MDN:slice、MDN:splice

建立一個新增品項到訂單的函式 handleAddToOrder 放在 DrinkCounter 裡,onAddOrder 負責通知外層元件:

function handleAddToOrder() {
  if (quantity === 0) return;

  const newItem: OrderItem = {
    id: crypto.randomUUID(),
    name,
    price,
    quantity,
    spec: { ...spec },
  };

  onAddOrder(newItem);
  setQuantity(0);
}

上面函式做了三件事:

  1. 杯數為 0 時不進行任何動作,有杯數時將品項、單價、數量與規格加入清單內,最後清空待加入的杯數。
  2. spec: { ...spec } 替新項目建立一份規格物件,把它與編輯中的規格分開。
  3. crypto.randomUUID() 產生這筆項目的識別字串,只在加入事件裡產生一次。

MDN:Crypto.randomUUID

外層元件 DrinkOrder 接到這一筆新增後,才更新清單:

function handleAddOrder(newItem: OrderItem) {
  setOrders((prevOrders) => [...prevOrders, newItem]);
}

prevOrders 是這裡收到的待更新清單,把既有項目展開,再放入 newItem,就會得到新的陣列,舊清單仍然保留原本的項目與長度。

updater 更新依據來自參數,函式只計算下一份資料,不修改 prevOrders,ID 已經在加入事件裡產生,不需要放進 updater 裡重新產生。

那麼事件怎麼接呢?

首先替 DrinkCounterProps 增加一個欄位,DrinkCounter 的參數也要解構出 onAddOrder:

type DrinkCounterProps = {
  name: string;
  price: number;
  onAddOrder: (item: OrderItem) => void // 表示在 prop 放入函式
};

接著在 DrinkOrder 的 JSX 把函式傳下去,紅茶與綠茶都使用同一個處理函式:

<DrinkCounter name="紅茶" price={30} onAddOrder={handleAddOrder} />
<DrinkCounter name="綠茶" price={25} onAddOrder={handleAddOrder} />

DrinkCounter 使用 state 記住杯數與規格,按下加入後透過 onAddOrder 通知 DrinkOrder 更新清單

用 filter 刪除指定項目

接著來看刪除,從畫面往回思考功能,通常會是點選一筆資料後按下刪除。通過按鈕傳入 item.id,由 filter 來排除項目:

// 放在 DrinkOrder 裡。
function handleDeleteOrder(id: string) {
  setOrders((prevOrders) => prevOrders.filter((item) => item.id !== id));
}

filter() 的條件描述的是「哪些要留下」,item.id !== id 留下編號不同的項目,把指定的那一筆排除,回傳新陣列,原本的陣列不會被刪改。

這裡很容易把條件寫反。如果使用 ===,就只留下原本要刪除的那一筆。也不能改用飲料名稱判斷,因為半糖紅茶和無糖紅茶可能都在清單裡,而我們一次只需要刪掉其中一筆。

當我們刪掉第一筆後,其他項目的位置會往前,但 ID 不用跟著重新編號,我們前面採用了 uuid,而刪除依賴 item.id,達到 項目排第幾個可以變,身分仍然保留。

用 map 修改杯數與規格

要幫某一筆加一杯,不用刪除再新增,可以用 map() 逐筆判斷,遇到指定編號時,回傳更新後的項目:

// 放在 DrinkOrder 裡,amount 使用 1 或 -1。
function handleChangeQuantity(id: string, amount: number) {
  setOrders((prevOrders) =>
    prevOrders.map((item) =>
      item.id === id // 指定編號要修改數量,新建項目後回傳更新的內容
        ? { ...item, quantity: Math.max(item.quantity + amount, 1) }
        : item
    )
  );
}

編號相同時展開原項目,再覆蓋新的杯數,編號不同時直接回傳原項目。這樣會得到新陣列,但只有要修改的那筆會變成新物件。

修改數量的單筆下限為一杯,使用 Math.max(..., 1),若要完全移除就按刪除。

同樣的概念拿來更新資料規格,下面修改 spec.sweetness:

// 放在 DrinkOrder 裡。
function handleOrderSugarFree(id: string) {
  setOrders((prevOrders) =>
    prevOrders.map((item) =>
      item.id === id
        ? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
        : item
    )
  );
}

最裡面先建立新的 spec,保留冰塊、覆蓋甜度;外面建立新項目,保留編號、飲料與杯數,map() 再把這些項目放進新陣列。

如果只寫 { ...item } 然後直接修改 item.spec.sweetness,會改到共用的規格。要保留舊資料,就得把這次修改經過的三層都處理好。

執行觀察

觀念看過之後,把按鈕和清單接進畫面吧,完整版都放下面了,直接替換 drink-order.tsx。

import { useState } from "react";
import { Link } from "react-router";

type DrinkSpec = {
  sweetness: "全糖" | "半糖" | "無糖";
  ice: "正常冰" | "少冰" | "去冰";
};

type OrderItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
  spec: DrinkSpec;
};

type DrinkNameProps = {
  name: string;
};

function DrinkName({ name }: DrinkNameProps) {
  return <h2>{name}</h2>;
}

type DrinkCounterProps = {
  name: string;
  price: number;
  onAddOrder: (item: OrderItem) => void;
};

function DrinkCounter({ name, price, onAddOrder }: DrinkCounterProps) {
  const [quantity, setQuantity] = useState(0);
  const [spec, setSpec] = useState<DrinkSpec>({
    sweetness: "全糖",
    ice: "正常冰",
  });

  function handleAdd() {
    const nextQuantity = quantity + 1;
    setQuantity(nextQuantity);
    console.log("[事件] 這次讀到的杯數", name, quantity);
    console.log("[事件] 準備更新的杯數與小計", name, nextQuantity, nextQuantity * price);
  }

  function handleAddThree() {
    setQuantity((prevQuantity) => prevQuantity + 3);
  }

  function handleRemove() {
    setQuantity((prevQuantity) => Math.max(prevQuantity - 1, 0));
  }

  function handleClear() {
    setQuantity(0);
  }

  function handleSweetness(sweetness: DrinkSpec["sweetness"]) {
    console.log("[事件] 指定甜度", name, sweetness);
    setSpec((prevSpec) => ({ ...prevSpec, sweetness }));
  }

  function handleIce(ice: DrinkSpec["ice"]) {
    console.log("[事件] 指定冰塊", name, ice);
    setSpec((prevSpec) => ({ ...prevSpec, ice }));
  }

  function handleResetSpec() {
    setSpec({ sweetness: "全糖", ice: "正常冰" });
  }

  function handlePreset() {
    setSpec((prevSpec) => ({ ...prevSpec, sweetness: "無糖", ice: "去冰" }));
  }

  function handleAddToOrder() {
    if (quantity === 0) return;

    const newItem: OrderItem = {
      id: crypto.randomUUID(),
      name,
      price,
      quantity,
      spec: { ...spec },
    };

    onAddOrder(newItem);
    setQuantity(0);
  }

  function handleReportLater() {
    console.log("[事件] 預約回報", name, quantity, spec.sweetness, spec.ice);

    setTimeout(() => {
      console.log("[延遲] 按下回報時的資料", name, quantity, spec.sweetness, spec.ice);
    }, 3000);
  }

  return (
    <div data-drink={name}>
      <DrinkName name={name} />
      <p>待加入 {quantity} 杯</p>
      <p>小計:{quantity * price} 元</p>
      <p>規格:{spec.sweetness}/{spec.ice}</p>
      <div>
        <button type="button" onClick={handleAdd}>加一杯</button>
        <button type="button" onClick={handleAddThree}>一次加三杯</button>
        <button type="button" onClick={handleRemove} disabled={quantity === 0}>
          減一杯
        </button>
        <button type="button" onClick={handleClear}>清空杯數</button>
      </div>
      <fieldset>
        <legend>{name}甜度</legend>
        <button type="button" aria-pressed={spec.sweetness === "全糖"}
          onClick={() => handleSweetness("全糖")}>
          全糖
        </button>
        <button type="button" aria-pressed={spec.sweetness === "半糖"}
          onClick={() => handleSweetness("半糖")}>
          半糖
        </button>
        <button type="button" aria-pressed={spec.sweetness === "無糖"}
          onClick={() => handleSweetness("無糖")}>
          無糖
        </button>
      </fieldset>
      <fieldset>
        <legend>{name}冰塊</legend>
        <button type="button" aria-pressed={spec.ice === "正常冰"}
          onClick={() => handleIce("正常冰")}>
          正常冰
        </button>
        <button type="button" aria-pressed={spec.ice === "少冰"}
          onClick={() => handleIce("少冰")}>
          少冰
        </button>
        <button type="button" aria-pressed={spec.ice === "去冰"}
          onClick={() => handleIce("去冰")}>
          去冰
        </button>
      </fieldset>
      <div>
        <button type="button" onClick={handleResetSpec}>恢復預設規格</button>
        <button type="button" onClick={handlePreset}>一鍵無糖去冰</button>
        <button type="button" onClick={handleReportLater}>3 秒後回報杯數與規格</button>
      </div>
      <label>
        {name}練習備註(未加入清單):
        <input type="text" placeholder="試著輸入晚點取" />
      </label>
      <div>
        <button type="button" onClick={handleAddToOrder} disabled={quantity === 0}>
          加入訂單清單
        </button>
      </div>
    </div>
  );
}

type DrinkDetailsProps = {
  name: string;
  description: string;
};

function DrinkDetails({ name, description }: DrinkDetailsProps) {
  const [isExpanded, setIsExpanded] = useState(false);

  function handleToggle() {
    const nextIsExpanded = !isExpanded;
    setIsExpanded(nextIsExpanded);
    console.log("[事件] 這次與接下來的介紹狀態", name, isExpanded, nextIsExpanded);
  }

  return (
    <div>
      <button type="button" aria-expanded={isExpanded} onClick={handleToggle}>
        {isExpanded ? "收合" : "展開"}「{name}」介紹
      </button>
      {isExpanded ? <p>{description}</p> : null}
    </div>
  );
}

export function DrinkOrder() {
  const [orders, setOrders] = useState<OrderItem[]>([]);
  const totalQuantity = orders.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = orders.reduce((sum, item) => sum + item.quantity * item.price, 0);

  function handleAddOrder(newItem: OrderItem) {
    setOrders((prevOrders) => [...prevOrders, newItem]);
  }

  function handleDeleteOrder(id: string) {
    setOrders((prevOrders) => prevOrders.filter((item) => item.id !== id));
  }

  function handleChangeQuantity(id: string, amount: number) {
    setOrders((prevOrders) =>
      prevOrders.map((item) =>
        item.id === id
          ? { ...item, quantity: Math.max(item.quantity + amount, 1) }
          : item
      )
    );
  }

  function handleOrderSugarFree(id: string) {
    setOrders((prevOrders) =>
      prevOrders.map((item) =>
        item.id === id
          ? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
          : item
      )
    );
  }

  function handleClearOrders() {
    setOrders([]);
  }

  function handleReportOrdersLater() {
    console.log("[事件] 預約回報的清單", JSON.stringify(orders));
    setTimeout(() => {
      console.log("[延遲] 按下回報時的清單", JSON.stringify(orders));
    }, 3000);
  }

  return (
    <main>
      <header>
        <p>DAY 14</p>
        <h1>飲料點餐練習</h1>
        <p>把不同規格加入清單,再依項目編號新增、刪除與修改。</p>
        <Link to="/variable">比較一般變數</Link>
      </header>
      <div>
        <section>
          <DrinkCounter name="紅茶" price={30} onAddOrder={handleAddOrder} />
          <DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
        </section>
        <section>
          <DrinkCounter name="綠茶" price={25} onAddOrder={handleAddOrder} />
          <DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
        </section>
      </div>
      <section aria-labelledby="order-heading">
        <h2 id="order-heading">訂單清單</h2>
        <p>共 {orders.length} 筆、{totalQuantity} 杯,合計 {totalPrice} 元</p>
        <button type="button" onClick={handleClearOrders} disabled={orders.length === 0}>
          清空訂單清單
        </button>
        <button type="button" onClick={handleReportOrdersLater}>
          3 秒後回報訂單清單
        </button>
        {orders.length === 0 ? (
          <p>還沒有訂單項目,先選杯數與規格,再加入清單。</p>
        ) : (
          <ul>
            {orders.map((item) => (
              <li key={item.id} data-order-id={item.id}>
                <h3>{item.name}</h3>
                <p>規格:{item.spec.sweetness}/{item.spec.ice}</p>
                <p>{item.quantity} 杯 × {item.price} 元,小計 {item.quantity * item.price} 元</p>
                <button type="button" onClick={() => handleChangeQuantity(item.id, 1)}>
                  此筆加一杯
                </button>
                <button type="button" onClick={() => handleChangeQuantity(item.id, -1)}
                  disabled={item.quantity === 1}>
                  此筆減一杯
                </button>
                <button type="button" onClick={() => handleOrderSugarFree(item.id)}>
                  此筆改無糖
                </button>
                <button type="button" onClick={() => handleDeleteOrder(item.id)}>
                  刪除此筆
                </button>
              </li>
            ))}
          </ul>
        )}
      </section>
    </main>
  );
}

清單使用 key={item.id},ID 在加入時已經存在,渲染時只需要讀取。千萬不要用飲料名稱或目前索引,也不要寫成 key={crypto.randomUUID()},否則每次渲染都會換一個身分。

data-order-id 是額外放在 HTML 上方便觀察的屬性,React 使用的 key 本身不會出現在 Elements 的 HTML 裡。

React:Keeping list items in order with key

總杯數與金額用 reduce() 累加,sum 是目前累加結果,最後的 0 是起始值,空清單也會得到零。這些值可以從 orders 算出,不需要另外放進 state,再跟著每個按鈕同步更新。

MDN:Array.prototype.reduce

紅茶品項操作

加入 2 杯半糖少冰紅茶,再加入 1 杯無糖去冰紅茶。

https://ithelp.ithome.com.tw/upload/images/20260928/20184332KhcPIndC92.png

紅色框表示半糖少冰加入 2 杯,無糖去冰加入 1 杯,可以看到是分別記數的。

第一筆按「此筆加一杯」

https://ithelp.ithome.com.tw/upload/images/20260928/20184332HAq5cHrulE.png

只有半糖少冰會增加 1 杯數量,無糖去冰數量不變。

刪除第二筆

https://ithelp.ithome.com.tw/upload/images/20260928/20184332Iv2dRJJkN2.png

只刪除第二筆無糖去冰的紅茶,應剩下 3 杯半糖少冰紅茶,且總額為 90。

錯誤修正練習

push 後再交回原陣列

以前在 JavaScript 新增資料,通常就直接 push(),以 handleAddOrder 為例:

// 錯誤示範。
function handleAddOrder(newItem: OrderItem) {
  orders.push(newItem);
  setOrders(orders);
}

看起來很合理,但會發生以下流程:

第一行改了舊清單,第二行交回 setter 同一個陣列。而 React 用 Object.is 比較 state,發現是相同參考,可以略過更新。

等到其他操作觸發更新後,才可能讓之前改過的資料出現在畫面上。

https://ithelp.ithome.com.tw/upload/images/20260928/201843326FMEsqjtmR.png

可以看到新增杯數事件發生了,但訂單筆數沒有變化。

雖然畫面後來看得到,但這個更新方式並不正確,它和昨天直接修改規格物件,再交回原物件,是同樣的問題,正確的流程是更新資料完成後,顯示在畫面上。

React:I’ve updated the state, but the screen doesn’t update

做個測試:

{
  const original = [{ id: "a", quantity: 1 }];
  const alias = original;
  const result = alias.push({ id: "b", quantity: 2 });

  console.log(Object.is(original, alias)); // true
  console.log(original.length);           // 2
  console.log(result);                    // 2
}

原陣列已被修改,而且 push() 回傳的是新長度,不是新陣列。因此 setOrders(orders.push(newItem)) 也不行,會把數字傳給需要 OrderItem[] 的 setter。

MDN:push 的回傳值

只複製陣列,畫面正確就夠了嗎?

前面已經知道淺拷貝只處理一層,現在把這個問題放進點餐畫面,把 DrinkOrder 裡的 handleOrderSugarFree 整份替換成:

// 錯誤示範
function handleOrderSugarFree(id: string) {
  const nextOrders = [...orders];
  const target = nextOrders.find((item) => item.id === id);
  if (!target) return;

  target.spec.sweetness = "無糖";
  console.log("[事件] 是否同一個陣列", Object.is(orders, nextOrders));
  console.log(
    "[事件] 是否同一個項目",
    Object.is(orders.find((item) => item.id === id), target)
  );
  setOrders(nextOrders);
}

find() 會找到符合編號的項目,找不到時是 undefined,但它不會複製找到的物件。

存檔、重新載入,加入紅茶兩杯、半糖少冰。先預約清單回報,再於三秒內按「此筆改無糖」,預期會看到:

觀察 結果
陣列參考比較 false,外層確實是新的
項目參考比較 true,裡面仍是同一筆物件
畫面 正常顯示無糖少冰
預約當下的 Log 半糖少冰
延遲回報 無糖少冰

https://ithelp.ithome.com.tw/upload/images/20260928/20184332j4lYJc74cN.png

畫面會更新是因為 setter 收到新陣列。但舊清單與新清單仍連到相同的項目,直接修改 target.spec.sweetness,也就改掉了舊回呼會讀取的資料。

還是跟先前一樣的觀念,舊 closure 並沒有切換去讀最新 state,它仍讀取舊陣列,只是沿著那份陣列找到的規格物件,已經被改成無糖了。

把函式恢復成前面用 map()、新項目和新 spec 的版本,存檔、重新載入,用相同順序再測一次。預期畫面一樣顯示無糖少冰,但延遲回報會保持半糖少冰。

比較 只複製陣列,再改內層 沿修改路徑建立新資料
畫面 無糖少冰 無糖少冰
延遲回報 無糖少冰 半糖少冰
舊規格是否保留 已被改掉 維持原樣

兩種寫法的畫面可能相同,但對舊資料的影響不同。把預約時、修改後和延遲回報一起看,才能確認這次更新有沒有把之前的內容也改掉。


這次把清單、updater,以及昨天的淺拷貝接起來了。只要先想清楚哪些資料共用、哪一層要改,再建立下一份清單,同款飲料就能各自保留不同規格。/images/emoticon/emoticon34.gif

明天接著用 state 表達表單輸入、送出中、成功與失敗的畫面狀態吧。

參考資料


上一篇
Day 13 : 珍奶微微,更新 state 裡的物件
下一篇
Day 15 : 簡潔有力,用 state 呈現表單輸入狀態
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言