iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
自我挑戰組

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

Day 13 : 珍奶微微,更新 state 裡的物件

  • 分享至 

  • xImage
  •  

吃飽才有力氣動,上班總是需要一點小快樂

今天目標

  1. 把飲料規格整理成物件
  2. 分辨修改物件與建立新物件
  3. 用展開語法保留其他欄位
  4. 理解淺拷貝與巢狀物件
  5. 讓物件更新接續前一步
  6. 錯誤修正練習

今天來處理點飲料一定會遇到的問題:甜度跟冰塊。

把飲料規格整理成物件

現在把甜度和冰塊,放到同一個物件內:

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

// useState 放在 DrinkCounter 的最上層。
const [spec, setSpec] = useState<DrinkSpec>({
  sweetness: "全糖",
  ice: "正常冰",
});

DrinkSpec 描述這個物件的欄位與型別, | 表示允許的幾種字串,所以甜度不能填成「微冰」。useState<DrinkSpec> 指定 state 的型別,後面的物件才是實際的初始資料。

杯數放在原本的 quantity,單價與飲料名稱一樣從 props 傳入,畫面直接讀取物件的欄位:

<p>規格:{spec.sweetness}/{spec.ice}</p>

分辨修改物件與建立新物件

以直覺的寫法來直接修改物件內的資料欄位:

spec.sweetness = "無糖";
setSpec(spec);

第一行直接修改原物件的欄位,稱為 mutation(直接修改),第二行雖然呼叫 setter,但交回去的仍是原本那個物件。

這是因為 useState 會用 Object.is 比較前後的 state。比較物件時,看參考是否相同,不會逐一檢查裡面的甜度和冰塊。當交回同一個物件時,React 可以略過更新,畫面就可能還停在舊的資料。

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

來試一下Object.is 的實際運作:

{
  const original = { sweetness: "全糖", ice: "正常冰" };
  const alias = original; // 這裡是指向 original 的參考
  const replacement = { sweetness: "無糖", ice: original.ice };

  console.log(Object.is(original, alias));       // 輸出 true
  console.log(Object.is(original, replacement)); // 輸出 false

  alias.sweetness = "半糖";
  console.log(original.sweetness);    // 半糖
  console.log(replacement.sweetness); // 無糖
}

alias 只是替同一個物件取另一個名字,改它的甜度,original 讀到的也會變。replacement 則是另一個物件,不受這次修改影響。

const 限制的是變數不能重新指定,沒有把物件本身凍結,另一個變數名稱,不代表另一份物件。

MDN:const

回到 React,放進 state 的物件應該視為唯讀。要改甜度就建立新的規格,再交給 setter,例如:

setSpec({
  sweetness: "無糖",
  ice: spec.ice,
});

這樣既交出了新物件,也沒有回頭修改舊的規格。

React:Treat state as read-only

用展開語法保留其他欄位

如果之後再增加容量、加料,每次只改甜度,卻要把其他欄位全部重寫一次,很容易會漏掉,這時可以使用展開語法(spread syntax):

setSpec({
  ...spec,
  sweetness: "無糖",
});

把原本的欄位放進新物件,再指定新的甜度。相同欄位以後面出現的值為準,順序要注意,假設原本是全糖、少冰:

寫法 結果
{ ...spec, sweetness: "無糖" } 無糖、少冰
{ sweetness: "無糖", ...spec } 全糖、少冰,舊甜度又蓋回來了
{ sweetness: "無糖" } 少了 ice,不符合 DrinkSpec

setSpec 接收的是下一個完整 state,不會自動幫我們補上沒寫的欄位。

在這個 TypeScript 專案中,漏掉 ice 會出現型別錯誤,補回資料就好,不要用 any 蓋過去。

MDN:Overriding properties

不過對於 ... 並不一定表示整份資料都獨立了,它還有一個很重要的限制。

理解淺拷貝與巢狀物件

展開語法建立的是淺拷貝(shallow copy),只複製一層。例如 spec 裡面的甜度字串,展開一層就能處理,但如果物件裡面還有物件,就要多看一層。

用一份假想訂單做獨立的 Console 測試:

{
  const originalOrder = {
    name: "紅茶",
    spec: { sweetness: "全糖", ice: "正常冰" }, // 放在內層的物件
  };
  const shallowCopy = { ...originalOrder }; // 淺拷貝

  console.log(Object.is(originalOrder, shallowCopy));           // 輸出 false
  console.log(Object.is(originalOrder.spec, shallowCopy.spec)); // 輸出 true

  originalOrder.name = "綠茶";
  console.log(shallowCopy.name); // 紅茶

  originalOrder.spec.ice = "去冰";
  console.log(shallowCopy.spec.ice); // 去冰
}

外層的 originalOrder 與 shallowCopy 是不同物件,所以改原本的 name,另一份仍然是紅茶。但兩邊的 spec 連到同一個物件,從任何一邊改裡面的冰塊,另一邊都會讀到變動。

因為兩邊讀取的是同一份 spec。

MDN:Copying and merging objects

要讓冰塊的修改不影響原本那份,就替 spec 也建立新物件:

{
  const originalOrder = {
    name: "紅茶",
    spec: { sweetness: "全糖", ice: "正常冰" },
  };
  const nextOrder = {
    ...originalOrder,
    spec: { ...originalOrder.spec, ice: "去冰" }, // 內層建立新物件
  };

  console.log(Object.is(originalOrder, nextOrder));           // false
  console.log(Object.is(originalOrder.spec, nextOrder.spec)); // false
  console.log(originalOrder.spec.ice, nextOrder.spec.ice);    // 正常冰 去冰
}

建立新的 spec,再讓新的訂單連到它,原本的訂單與規格都保持不變。需要改哪一層,就從那一層往外建立新物件,不能只換外層,再直接修改共用的內層。

React:Updating a nested object

深拷貝與只複製修改路徑

既然淺拷貝還要注意這些,那乾脆一開始就深拷貝呢?

其實可以,只是會連沒改動的內層物件一起重新建立。假設訂單還有顧客資料,只改冰塊時,顧客資料並沒有變,每次都深拷貝,就把這些不需要重建的部分一起複製了。

訂單裡的資料 只修改冰塊時
訂單外層 建立新物件,連到新的規格
spec 建立新物件,保留甜度、修改冰塊
沒修改的顧客資料 沿用原本的物件

這樣能減少不必要的複製,也保留「同一個參考,表示這一支沒改動」的判斷依據,讓依參考比較跳過重算的最佳化能發揮作用。

React 要求的是不要修改既有 state,不是每次更新都要深拷貝。沒有改的資料可以繼續共用,但共用的資料,雙方都要把它視為唯讀。 之後要修改顧客資料,再替那一支建立新物件。

React:Why is mutating state not recommended in React?

新物件的成本與回收

那還有另一個問題:每次更新都建立新物件,記憶體不會越堆越多嗎?

建立物件確實有配置和回收的成本,但舊物件不會因為曾經放進 state,就永久留在記憶體裡。當程式已經無法再存取它,JavaScript 的 垃圾回收機制(GC) 就能回收,實際時間由引擎決定,不是呼叫 setter 後馬上釋放。

如果還有計時器的回呼要讀取舊規格,那份資料就可能暫時保留。若把每次更新都存進沒有上限的歷史清單,也得考慮這些持續保留的資料。

以今天點一下按鈕、修改兩個欄位的規模,通常不用為此改成直接修改 state。等到資料量大、更新頻繁,再量測複製與回收成本,調整資料結構或更新方式。

MDN:Memory management

讓物件更新接續前一步

知道物件怎麼複製後,再來看只有甜度、冰塊兩個字串的 spec,接上昨天的 updater:

// 示意片段:在 DrinkCounter 裡。
function handleSweetness(sweetness: DrinkSpec["sweetness"]) {
  setSpec((prevSpec) => ({
    ...prevSpec,
    sweetness,
  }));
}

prevSpec 這一步接到待更新規格,會建立新物件,保留其他欄位,再放入新的甜度。

物件裡單獨寫 sweetness,是 sweetness: sweetness 的簡寫,當屬性名稱與變數名稱相同可以這樣做。
MDN:Property definitions

DrinkSpec["sweetness"] 則取出甜度欄位的型別,讓參數只能接收全糖、半糖或無糖。

還有一個語法要注意:=> ({ ... }) 的小括號,是為了直接回傳物件,如果使用函式本體的大括號,就要明確寫 return,例如:

setSpec((prevSpec) => {
  return { ...prevSpec, sweetness: "無糖" };
});

updater 要保持純粹,只計算並回傳下一份資料,不修改 prevSpec,也不在裡面安排計時器。

單次更新用 { ...spec, sweetness: "無糖" } 也可以,但這次用 updater 是要明確表達更新依據。

React:Updating state based on the previous state

執行觀察

整份替換 drink-order.tsx,把甜度與冰塊按鈕功能放進點餐畫面。

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

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

type DrinkNameProps = {
  name: string;
};

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

type DrinkCounterProps = {
  name: string;
  price: number;
};

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

  console.log("[渲染] 飲料資料", name, quantity, spec.sweetness, spec.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 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={handleReportLater}>3 秒後回報杯數與規格</button>
      </div>
      <label>
        {name}備註:
        <input type="text" placeholder="試著輸入晚點取" />
      </label>
    </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() {
  return (
    <main>
      <header>
        <p>DAY 13</p>
        <h1>飲料點餐練習</h1>
        <p>建立新的規格物件,更新甜度與冰塊。</p>
        <Link to="/variable">比較一般變數</Link>
      </header>
      <div>
        <section>
          <DrinkCounter name="紅茶" price={30} />
          <DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
        </section>
        <section>
          <DrinkCounter name="綠茶" price={25} />
          <DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
        </section>
      </div>
    </main>
  );
}

把甜度與冰塊個別放在 fieldset 裡,legend 顯示組別名稱,aria-pressed 表達目前選中的按鈕。畫面也直接列出規格,方便對照。

延遲回報也增加甜度與冰塊,透過 Log 逐一印出字串和數字,方便比較記錄當下的值。

修改甜度,再修改冰塊

預設兩款飲料都是 0 杯、全糖、正常冰。

操作順序:

  1. 按「半糖」
  2. 接著按「少冰」
  3. 按「加一杯」
  4. 按「一次加三杯」 -> 這裡會依照半糖少冰的規格一次加三杯
  5. 按「恢復預設規格」 -> 這裡只會更新甜度冰塊
  6. 改成無糖,再按「清空杯數」

看結果:

https://ithelp.ithome.com.tw/upload/images/20260927/20184332UxGMtbvc6L.png

預約回報後,修改規格

重新載入,把紅茶設成 1 杯、半糖、少冰,按「3 秒後回報杯數與規格」,接著在回報出現前改成無糖。

https://ithelp.ithome.com.tw/upload/images/20260927/20184332bGqkSAEQQI.png

這次回報連到的是安排當次的 (半糖少冰) spec,後來修改甜度建立了新物件,但沒有改舊規格,所以仍能回報原本的半糖。

沒有改動舊物件,舊回呼才有原本的內容可以讀。

錯誤修正練習

直接修改,為什麼加杯後才看見?

前面說直接修改物件可能不會更新畫面,現在來對照看看,暫時把 DrinkCounter 裡的 handleSweetness 替換成:

function handleSweetness(sweetness: DrinkSpec["sweetness"]) {
  const nextSpec = spec;
  nextSpec.sweetness = sweetness;
  console.log("[事件] 是否同一個物件", Object.is(spec, nextSpec));
  console.log("[事件] 物件裡的甜度", spec.sweetness);
  setSpec(nextSpec);
}

重新載入,從 0 杯、全糖、正常冰開始,按紅茶的「無糖」,原本預期會印出 true 和「無糖」,但畫面卻是全糖,再按一次紅茶的「加一杯」,甜度也跟著顯示成無糖了。

https://ithelp.ithome.com.tw/upload/images/20260927/201843322pbLoMmX5R.png

看起來是加杯順便更新了甜度,其實是因為 quantity 的更新觸發 DrinkCounter 而重新計算 JSX,但這時的 spec 早就被改過,只是要依賴另一個操作,才能把先前的修改資料顯示出來。

重新載入,全糖時先按下預約回報,然後在三秒內按無糖,這次先不要按加杯,延遲回報會讀到無糖,畫面仍可能停在全糖。

https://ithelp.ithome.com.tw/upload/images/20260927/20184332P6O7ciPcbB.png

原因一樣:舊回呼讀取的物件被直接改掉了,但資料還需要另一個操作才能渲染出來。

畫個流程:
https://ithelp.ithome.com.tw/upload/images/20260927/20184332Ab3VGQJzRN.png

從這個回報結果,追蹤 closure 讀取的物件與修改之間的關係,可以看到回呼在執行時讀取 spec.sweetness。

其中的 spec 來自預約當次的渲染,修改甜度的程式也連到同一個物件,當甜度改成無糖,三秒後 closure 讀取這個物件時會回報無糖,但畫面因為傳入相同的物件參考,React 可以略過這次更新,因此畫面仍停在全糖,直到加減杯數後,重新計算畫面才會顯示。

React:set functions 的注意事項

一鍵設定無糖去冰

接著加一個常用規格,讓一顆按鈕同時設定甜度與冰塊。先確認前面的甜度更新已恢復正確,在 DrinkCounter 的 handleResetSpec 旁新增:

// 兩行都從同一份 spec 建立新物件
function handlePreset() {
  setSpec({ ...spec, sweetness: "無糖" });
  setSpec({ ...spec, ice: "去冰" });
}

在「恢復預設規格」旁新增按鈕:

<button type="button" onClick={handlePreset}>一鍵無糖去冰</button>

這次明明兩行都有建立新物件,應該沒問題了吧?

讓我們重新載入,從全糖、正常冰開始,按這顆按鈕看看。

https://ithelp.ithome.com.tw/upload/images/20260927/20184332LJsF3ndlHy.png

順序 交給 setter 的物件
第一次 { sweetness: "無糖", ice: "正常冰" }
第二次 { sweetness: "全糖", ice: "去冰" }

最後會是全糖、去冰,第二行展開的是事件開始時的 spec,又把全糖帶回來了,這是因為 setter 接收到兩份完整物件,第二份替換掉前一步的結果,沒有自動把冰塊合併進第一份規格。

這就要回到昨天的更新佇列(queue),如果把 handlePreset 換成兩個 updater:

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

第一步建立無糖、正常冰,第二步接到這份規格,再建立無糖、去冰。重新載入後重測,就正常了。

不過這個需求本來就是一次設定兩個欄位,其實也可以把它整理成一個 updater:

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

建立新物件的易混淆寫法

整理今天容易混淆的幾種寫法,假設 spec 是已建立的物件:

寫法 問題與修正方向
const nextSpec = spec,再修改欄位 只是另一個名字,要建立新物件
{ sweetness: "無糖", ...spec } 舊欄位蓋回新值,改成先展開、再覆蓋
setSpec({ sweetness: "無糖" }) 缺少冰塊,setter 不會自動補齊
updater 裡修改 prevSpec.ice 傳入函式也不能直接修改舊物件
先修改 spec.ice,再 setSpec({ ...spec }) 雖然有新物件,舊資料已經先被改過
只複製訂單外層,再修改共用的 spec 內層也要建立新物件

漏寫 return 的物件:

// 錯誤示意
setSpec((prevSpec) => {
  sweetness: "無糖";
});

這裡的 {} 是函式本體,sweetness: 被當成標籤,並不是回傳物件。

TypeScript 會指出 updater 沒有回傳需要的規格,改成 ({ ...prevSpec, sweetness: "無糖" }),或明確寫 return 就能修正。

但直接修改物件的寫法不一定會被型別檢查擋下來,所以還是把操作前後的畫面與資料一起比較完整。


原本以為只是把杯數換成物件,結果又碰到參考、淺拷貝和舊快照,還是要看清楚哪些資料共用、哪一層要改,再建立對應的新資料。

明天把飲料與規格放進訂單陣列,接著處理新增、刪除與修改,讓點餐功能再往前一步。/images/emoticon/emoticon34.gif

參考資料


上一篇
Day 12 : 接二連三,連續更新 state
下一篇
Day 14 : 不慌不忙,更新 state 裡的陣列
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言