iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
自我挑戰組

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

Day 12 : 接二連三,連續更新 state

  • 分享至 

  • xImage
  •  

按讚,訂閱,開啟小鈴噹,是三個不同的事件~

今天目標

  1. 連續三次更新的結果
  2. 理解同一次事件的批次處理
  3. 用函式更新依序累加杯數
  4. 追蹤queue的串接與計算
  5. 比較替換與累加的先後順序
  6. 錯誤修正練習

昨天我們有做了一個小示範,連續更新 state 三次發現結果是一樣的,但沒有細講原因,且總是會有特殊的需求,需要在同一次事件中連續更新 state (明明就可以用 Local...),就來看看吧。

連續三次更新的結果

原本加一杯是 setQuantity(quantity + 1),那連寫三次:

// 示意片段
function handleAddThreeValue() {
  setQuantity(quantity + 1);
  setQuantity(quantity + 1);
  setQuantity(quantity + 1);
}

從 0 杯開始,很容易把這三行想成「加一杯、再加一杯、再加一杯」,最後得到 3 杯。

不過,昨天已經知道同一次事件裡的 quantity 不會被 setter 改寫。把杯數 0 代入,程式其實是:

// 代入數字理解執行過程
setQuantity(0 + 1);
setQuantity(0 + 1);
setQuantity(0 + 1);

每一行都先用同一份快照算出 1,再把這個數字交給 React。

順序 讀到的 quantity 交給 React 的內容
第一次 0 把杯數設成 1
第二次 0 把杯數設成 1
第三次 0 把杯數設成 1

於是最後只會顯示 1 杯,如果原本是 2 杯,三行交出去的都是 3,最後也只會增加一杯。

在這裡的連續三行讀取的是相同事件快照,所以呼叫三次 setter,不會每次都拿到上一行更新後的 state。

React:Rendering takes a snapshot in time

理解同一次事件的批次處理

順便複習畫面更新流程,以 0 杯按一次增加事件,提出三個杯數來看,流程是:

按下按鈕
  ↓
同一次事件提出三個「把杯數設成 1」的更新
  ↓
事件處理結束,React 根據這批更新決定新畫面
  ↓
元件使用 quantity = 1 計算 JSX,提交後顯示 1 杯

同一次事件中 React 會收集這批更新,等事件處理程式執行完後再更新畫面,並不會每呼叫一次 setter,就渲染讓使用者看到中間結果,這就是批次處理(batching)。

React:React batches state updates

快照說明三行為什麼都算出 1,批次處理說明畫面為什麼不在三行之間更新。

但不能因為最後只增加一杯,就把批次處理記成「React 只保留最後一行」,實際上是 React 將處理放入了佇列,接下來的函式更新(updater function),會讓前面的步驟都參與計算。

另外一次點擊裡連續處理三次,與真的按三次按鈕,要分開理解。

對於從 0 杯按「加一杯」,等畫面變成 1 杯再按,在 React 裡,這些點按是分開的事件,下一次點擊使用的是新渲染的處理函式,因此可以接著得到 2 杯、3 杯。

用函式更新依序累加杯數

如果希望達到一次事件中完成連續的更新操作,可以建立一個函式並交給 setQuantity:

// 示意片段
function handleAddThreeFunctional() {
  setQuantity((prevQuantity) => prevQuantity + 1); // 建立一個箭頭函式交給 setQuantity
  setQuantity((prevQuantity) => prevQuantity + 1);
  setQuantity((prevQuantity) => prevQuantity + 1);
}

這裡的 setQuantity 是 useState 提供的 setter,而傳進去的 (prevQuantity) => prevQuantity + 1,則是我們寫的更新函式,稱為 updater。

prevQuantity 是自己取的參數名稱,React 會把這個更新步驟接到的 state 傳進來,函式再回傳下一個值,也就是每一步都接續前一步的結果,不一定等於按下按鈕時畫面上的杯數。

React:Updating state based on the previous state

為什麼換成改用函式更新就可以了?讓我們接著看 React 內部是怎麼運作的。

追蹤 queue 的串接與計算

回到 const [quantity, setQuantity] = useState(0)。
React 在初始化這個 Hook 時,會替它建立一個更新佇列(queue),並讓回傳的 setQuantity 對應到這個 queue。

換句話說,佇列由 React 建立與保存,而 setter 知道要把更新交到哪裡。

React 19.3.0:建立 state Hook 與 setter

當每次呼叫 setQuantity(...),React 就建立一筆更新,用 action 保存我們傳入的數值或函式。前面連續呼叫三次,就產生三筆更新,依呼叫順序交給同一個 Hook 的 queue。

React 19.3.0:建立更新節點

這些待處理更新會串成環狀單向鏈結串列,每筆更新是一個節點,箭頭表示「下一筆在哪裡」,最後一筆再接回第一筆,以 U1、U2、U3 表示依序加入的三筆更新,我們用簡化的圖來看:

https://ithelp.ithome.com.tw/upload/images/20260926/20184332hutD3zy8FH.png

React 記住尾端 U3,就能沿著尾端的連線找到開頭 U1。

接著看這個環怎麼一步步接起來:

加入的更新 串接後的樣子 React 記住的尾端
第一筆 U1 U1 → U1 U1
第二筆 U2 U1 → U2 → U1 U2
第三筆 U3 U1 → U2 → U3 → U1 U3

只有第一筆時,讓它接回自己,就形成最小的一個環。之後每加入一筆,都是接到原本的尾端後面。

例如加入第三筆 U3 前,尾端是 U2,它原本接回開頭 U1。React 會依序做三件事:

  1. 讓新節點 U3 接回原本的開頭 U1。
  2. 把舊尾端 U2 原本接回 U1 的連線,改成接向 U3。
  3. 把記住的尾端從 U2 改成 U3。

先讓新節點接住開頭,再讓舊尾端接上新節點,最後記住新的尾端。 前面的更新順序保留下來,就形成圖中的 U1 → U2 → U3 → U1。

追蹤這三筆更新時,從開頭 U1 沿箭頭走過 U2、U3,回到起點就結束這一輪。每走到一筆,React 就根據它記錄的更新內容決定下一個值:

  • 傳入數值:把這一步的結果設成該數值。
  • 傳入函式:把前一步的結果交給函式,接住它回傳的新值。

箭頭串起的是更新的順序,前一步算出的 state 則成為下一步的計算輸入。

按下按鈕時是 0 杯,數值版本的三筆更新都是「設成 1」,函式版本的三筆更新則都是「接續前一步加一」:

讀取的節點 三次傳入數值 1 三次傳入加一函式
U1 收到 0,指定結果為 1 收到 0,計算 0 + 1 = 1
U2 收到 1,指定結果仍為 1 收到 1,計算 1 + 1 = 2
U3 收到 1,指定結果仍為 1 收到 2,計算 2 + 1 = 3

數值版本每次都指定相同結果;函式版本則把前一步算出的值繼續加一,所以最後得到 3 杯。換成從 2 杯開始,就會依序算出 3 → 4 → 5,最後顯示 5 杯。

表格描述的是計算過程,實際畫面不會把每一步的中間值都顯示出來,且雖然 updater 能接續計算,但不代表外層的 quantity 會改變,在三個 setter 後面讀它,仍然是這次事件的快照。

updater 只會負責計算並回傳結果,仍然要保持純粹性,不能在裡面送出訂單、安排計時器、修改外部資料或呼叫另一個 setter。

還是要注意 StrictMode 在開發時可能額外呼叫 updater,並忽略其中一次結果。
React:My initializer or updater function runs twice

今天用這個簡化流程看 React 在同一次同步事件中的更新,queue 保存每一步要做的事與順序,React 根據這些更新算出下一次畫面使用的 state。

React:Queueing a Series of State Updates

比較替換與累加的先後順序

看過串接方式後,再把數值與函式放進同一批更新。例如原本是 2 杯,把「先清空,再加一杯」放在一次事件:

// 示意片段
function handleQueueExperiment() {
  setQuantity(0); // 清空為 0
  setQuantity((prevQuantity) => prevQuantity + 1); // prevQuantity 取得 0,計算後為 1
}

原本是 2 杯,第一步先指定成 0,下一個 updater 接到 0,最後就得到 1。

但如果交換兩行的順序,則是先算出 3,接著又指定成 0:

起始杯數 更新順序 推演 最後杯數
2 指定 0,再用函式加一 2 → 0 → 1 1
2 先用函式加一,再指定 0 2 → 3 → 0 0

數值更新會指定這一次的結果,函式更新則接續前面的計算,依序追下來,就能解釋為什麼同樣兩行,交換位置會得到不同的杯數。

React:What happens if you replace state after updating it

執行觀察

接著把前面的兩種加三杯寫法與順序觀察,放進同一個點餐畫面,下面完整程式替換掉昨天的drink-order.tsx。

  • 「連寫三次(數值)」刻意保留只加一杯的結果
  • 旁邊的「一次加三杯(函式)」用來比較。
  • 「佇列順序測試」先清空再加一。
import { useState } from "react";
import { Link } from "react-router";

type DrinkNameProps = {
  name: string;
};

function DrinkName({ name }: DrinkNameProps) {
  console.log("[渲染] DrinkName", name);
  return <h2>{name}</h2>;
}

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

function DrinkCounter({ name, price }: DrinkCounterProps) {
  const [quantity, setQuantity] = useState(0);

  console.log("[渲染] DrinkCounter", name, quantity);

  function handleAdd() {
    const nextQuantity = quantity + 1;

    setQuantity(nextQuantity);
    console.log("[事件] 這次讀到的杯數", name, quantity);
    console.log("[事件] 準備更新的杯數", name, nextQuantity);
    console.log("[事件] 準備更新的小計", name, nextQuantity * price);
  }
  
  // 連寫三次(數值)
  function handleAddThreeValue() {
    console.log("[事件] 數值三次,開始", name, quantity);
    setQuantity(quantity + 1);
    setQuantity(quantity + 1);
    setQuantity(quantity + 1);
    console.log("[事件] 數值三次,呼叫後", name, quantity);
  }

  // 一次加三杯(函式)
  function handleAddThreeFunctional() {
    console.log("[事件] 函式三次,開始", name, quantity);
    setQuantity((prevQuantity) => prevQuantity + 1);
    setQuantity((prevQuantity) => prevQuantity + 1);
    setQuantity((prevQuantity) => prevQuantity + 1);
    console.log("[事件] 函式三次,呼叫後", name, quantity);
  }

  // 佇列順序測試
  function handleQueueExperiment() {
    console.log("[事件] 先清空再加一", name, quantity);
    setQuantity(0); 
    setQuantity((prevQuantity) => prevQuantity + 1);
  }

  function handleClear() {
    console.log("[事件] 清空杯數", name);
    setQuantity(0);
  }

  function handleRemove() {
    console.log("[事件] 減一杯", name);
    setQuantity(Math.max(quantity - 1, 0));
  }

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

    setTimeout(() => {
      console.log("[延遲] 按下回報時的杯數", name, quantity);
    }, 3000);
  }

  return (
    <div data-drink={name}>
      <DrinkName name={name} />
      <p>已選 {quantity} 杯</p>
      <p>小計:{quantity * price} 元</p>
      <div>
        <button type="button" onClick={handleAdd}>
          加一杯
        </button>
        <button type="button" onClick={handleAddThreeValue}>
          連寫三次(數值)
        </button>
        <button type="button" onClick={handleAddThreeFunctional}>
          一次加三杯(函式)
        </button>
        <button type="button" onClick={handleQueueExperiment}>
          佇列順序測試
        </button>
        <button type="button" onClick={handleClear}>
          清空杯數
        </button>
        <button type="button" onClick={handleRemove} disabled={quantity === 0}>
          減一杯
        </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);

  console.log("[渲染] DrinkDetails", name, isExpanded);

  function handleToggle() {
    const nextIsExpanded = !isExpanded;

    setIsExpanded(nextIsExpanded);
    console.log("[事件] 這次介紹狀態", name, isExpanded);
    console.log("[事件] 接下來的介紹狀態", name, nextIsExpanded);
  }

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

export function DrinkOrder() {
  console.log("[渲染] DrinkOrder");

  return (
    <main>
      <header>
        <p>DAY 12</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>
  );
}

這次還新增了 Log 放在點擊處理函式,updater 裡只留下計算。可以比較事件的快照與新畫面,也不會把觀察操作放進 updater 裡。

點擊 "連寫三次"

第一次點按"連寫三次"
https://ithelp.ithome.com.tw/upload/images/20260926/20184332gy0qQnfAxx.png

第二次點按"連寫三次"
https://ithelp.ithome.com.tw/upload/images/20260926/201843329eIS23Xo4Y.png

因為同一事件內的 quantity 不變,可以看到分次點按連寫三次,結果跟"加一杯"一樣。

點擊 "一次加三杯"

第一次點按"一次加三杯"
https://ithelp.ithome.com.tw/upload/images/20260926/20184332RoqSPAFydD.png

第二次點按"一次加三杯"
https://ithelp.ithome.com.tw/upload/images/20260926/20184332CRYz9MkbZM.png

效果不同了,因為我們用函式更新(updater)處理新增,每次都會加三杯。

先清空,再加一杯

https://ithelp.ithome.com.tw/upload/images/20260926/20184332Lh9O7ddBlC.png

我們沿用前面一次加三杯的執行結果,按下佇列 "順序測試" 後,可以看到先把計算結果重設為 0,再加一,最後顯示 1 杯。

錯誤修正練習

前面的先清空再加一正常,接著來看錯誤的使用了箭頭函式,但函式讀取外層快照,沒有使用前一步的計算結果:

// 錯誤示範:計算仍讀取外層 quantity。
setQuantity(() => quantity + 1);
setQuantity(() => quantity + 1);
setQuantity(() => quantity + 1);

假設從 0 杯開始,三個函式都回傳外層的 0 + 1,雖然傳給 setter 的已經是函式,但沒有使用 React 傳進來的參數,計算也就沒有接續前一步的結果。

https://ithelp.ithome.com.tw/upload/images/20260926/20184332bCzGHOJeJ6.png

把計算改回 (prevQuantity) => prevQuantity + 1,就沒問題了。


光是這個 state 就理解好久,重點就是注意事件和渲染之間的關係,以及同一次事件中更新的 state 內容,才不會發生事件觸發沒反應,或是觸發了但結果異常的情況。

明天把杯數以外的飲料規格整理成物件,接著修改甜度與冰塊,看看更新物件時為什麼需要建立新資料。

但這還只是冰山一角.../images/emoticon/emoticon20.gif

參考資料


上一篇
Day 11 : 此時此刻,理解 state 的渲染快照
下一篇
Day 13 : 珍奶微微,更新 state 裡的物件
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言