iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
自我挑戰組

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

Day 11 : 此時此刻,理解 state 的渲染快照

  • 分享至 

  • xImage
  •  

state 的多重宇宙?

今天目標

  1. 理解每次渲染的快照
  2. 比較更新前後的讀值
  3. 追蹤延遲操作的快照
  4. 分辨目前值與下一個值
  5. 錯誤修正練習

理解每次渲染的 state

今天主要觀察 quantity,來看這一段:

const [quantity, setQuantity] = useState(0);

這裡要回顧一下昨天的流程:

觸發 -> 渲染 -> 提交

當 React 呼叫 DrinkCounter 時,useState 會提供此次渲染的杯數,元件再用它計算畫面,並建立事件處理函式。

可以把當下產生的 JSX 和事件處理函式,理解成根據當時資料形成的一張快照,以紅茶從 0 杯增加到 1 杯為例:

觀察位置 杯數為 0 的當次渲染 杯數更新為 1 的當次渲染
quantity 0 1
杯數文字 已選 0 杯 已選 1 杯
紅茶小計 0 元 30 元
這次建立的 handleAdd 用 0 + 1 提出更新 用 1 + 1 提出更新

state 由 React 保存,所以元件再次執行時,可以從 useState 取得更新後的值。

但新的渲染拿到 1,不會把前一次函式裡的 quantity 也改成 1。

React:Rendering takes a snapshot in time

比較更新前後的讀值

接續看增加杯數的處理函式,在更新前後各加一行 Log:

// 示意片段:DrinkCounter 裡的 handleAdd。
function handleAdd() {
  console.log("[事件] 更新前", name, quantity);
  setQuantity(quantity + 1);
  console.log("[事件] 呼叫更新後", name, quantity);
}

如果操作前是 0 杯,很容易直覺認為:第一行是 0,加一杯後,第二行就是 1。

但這兩行都在同一次處理函式裡,使用同一份 quantity。把數字代入,就比較容易理解:

// 代入數字理解執行過程
console.log("[事件] 更新前", "紅茶", 0);
setQuantity(0 + 1); // 這裡不會立刻更新當前的 quantity
console.log("[事件] 呼叫更新後", "紅茶", 0);

setQuantity(1) 把下一個值交給 React,沒有重新賦值給這裡的 quantity,所以兩行都會印出 0。

之後 React 再次呼叫元件,useState 才提供新的杯數 1,新的 JSX 回傳「已選 1 杯」,提交後畫面跟著更新。這時會看到:事件裡印出舊杯數,畫面卻顯示新杯數。

更極端一點可以這樣觀察:

// 假設 quantity 目前是 0。
function handleAdd() {
  console.log("[事件] 更新前", name, quantity);
  setQuantity(quantity + 1);
  setQuantity(quantity + 1);
  setQuantity(quantity + 1);
  console.log("[事件] 呼叫更新後", name, quantity);
}

會看到畫面仍然是「已選 1 杯」,這是因為當下的 quantity 是 0,而 setQuantity,並不會立刻重新賦值,也就是:

  console.log("[事件] 更新前", name, quantity);
  setQuantity(0 + 1);
  setQuantity(0 + 1);
  setQuantity(0 + 1);
  console.log("[事件] 呼叫更新後", name, quantity);

等畫面顯示 1 杯,再按一次增加杯數,使用的就會是新一次渲染所建立的處理函式,就是這次渲染取得的值,不會是初始值 0。

React:I’ve updated the state, but logging gives me the old value

追蹤延遲操作的快照

上面的事件是立即讀到舊值,可能還會讓人覺得只是更新來不及,那就安排三秒後再讀看看吧。

在 DrinkCounter 裡增加一個回報函式:

// 示意片段:放在 DrinkCounter 裡、return 前。
function handleReportLater() {
  console.log("[事件] 預約回報", name, quantity);

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

setTimeout 的第一個參數是計時之後執行的函式,第二個參數是 3000 毫秒。會先安排函式,目前的程式可以繼續執行,頁面也可以操作,但實際執行時間也可能晚於三秒。

MDN:setTimeout

假設紅茶是 1 杯時按下回報,接著在回報出現前按增加到 3 杯。這時畫面已經變了,延遲訊息卻仍會回報 1。

原因是裡面的函式仍能讀取建立時所在作用域的變數,這是 JavaScript 的 (closure)。這個函式是連到杯數為 1 的那次元件執行。之後元件再次執行,會有另一份 quantity,不會替換掉已安排的函式所使用的那一份。

MDN:Closures

closure 本身不代表把所有變數都複製起來,在這個範例裡,是每次渲染有各自的 quantity,而更新 state 也沒有改寫舊的區域變數。

因此,等久一點,不會讓舊回呼自動改用新一次渲染的 state。 這也說明為什麼不能只用「更新是非同步的」來解釋讀到舊數值的原因。

React:State over time

分辨目前值與下一個值

現在知道同一次事件裡的 quantity 不會改變後,如果想在增加杯數時回報「這次準備更新成幾杯」,可以怎麼寫?

那就先算出下一個值,再把它交給 React 和事件:

// 示意片段:完成觀察後,再套用到 handleAdd。
function handleAdd() {
  const nextQuantity = quantity + 1;

  setQuantity(nextQuantity);
  console.log("[事件] 這次讀到的杯數", name, quantity);
  console.log("[事件] 準備更新的杯數", name, nextQuantity);
}

從 0 杯開始,quantity 是 0,nextQuantity 則是這次算出的 1,這樣就能分別使用操作依據的值,以及準備更新的值。

但要注意 nextQuantity 只是一般區域變數,不是另一份 state。

觀察 使用變數
這次點擊依據的杯數 quantity
這次準備更新的杯數 nextQuantity
三秒後回報按下按鈕時的杯數 已安排的函式中的 quantity

分清楚需求的時間點,就不會把「按下當時的杯數」和「已安排的函式執行當下的最新杯數」所需要的變數混在一起囉。

執行觀察

接著把前面的觀察點接到點餐頁面吧,來看更新前後讀取 quantity 的版本。

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() {
    console.log("[事件] 更新前", name, quantity);
    setQuantity(quantity + 1);
    console.log("[事件] 呼叫更新後", name, quantity);
  }

  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={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() {
    console.log("[事件] 切換介紹", name);
    setIsExpanded(!isExpanded);
  }

  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 11</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>
  );
}

這次 DrinkCounter 加上小計,由 quantity * price 計算。

計時器放在 handleReportLater 裡,按下按鈕才安排一次,但不要放到元件本體,否則每次渲染都可能再安排一筆。

比較一次加杯的三個位置

從紅茶 0 杯開始,按一次「加一杯」:

https://ithelp.ithome.com.tw/upload/images/20260925/20184332dhvJAu8ToX.png

前兩行來自同一次事件,讀到的杯數都是 0。第三行已經是新的元件執行,拿到 1,畫面也會顯示紅茶 1 杯、小計 30 元。

LOG 杯數 說明
更新前的事件 Log 0 按下按鈕時使用的快照
呼叫更新後的事件 Log 0 仍是同一次處理函式的快照
新一次渲染與更新後的畫面 1 React 用新的 state 計算 JSX,再提交到 DOM

畫面變了,延遲回報是否跟著變

重新載入首頁,先把紅茶加到 1 杯,清空 Console,按「3 秒後回報杯數」,接著在回報出現前按兩次「加一杯」。

https://ithelp.ithome.com.tw/upload/images/20260925/20184332mKk3BKtaJK.png

首先看到預約訊息:

[事件] 預約回報 紅茶 1

加杯後,畫面變成 3 杯、小計 90 元。等回呼執行,預期出現:

[延遲] 按下回報時的杯數 紅茶 1

畫面上的 3 和回報的 1,各自描述不同時間點。前者是後來更新的杯數,後者是安排回報時的快照。

再換一個操作確認,現在紅茶是 3 杯,按回報後清空。

https://ithelp.ithome.com.tw/upload/images/20260925/20184332lSWQ4VPHoF.png

清空杯數後,回報仍讀到 3,但畫面跳回 0 杯,因為這個回呼只印出資料,沒有呼叫更新函式,所以讀到舊快照,不等於把 state 還原。

錯誤修正練習

用 await 等待新杯數

下面片段也是錯誤的認為等候後 quantity 就會更新。

async function handleAdd() {
  await setQuantity(quantity + 1);
  console.log("以為等待後就會得到新杯數", quantity);
}

但 setQuantity 不回傳代表更新完成的 Promise,這個 await 不能拿來等待 React 提交完成,也不會把這裡的 quantity 換成下一次渲染的值。

如果想真正觀察到變更後的資料,建立一個新的區域變數來處理相同的計算,例如前面提到的 nextQuantity 做法就可以囉。

React:set functions 的回傳值


今天範例其實還有一個問題:連續三次 setQuantity(quantity + 1) 在同一個事件中,增加只有 1 杯,等明天我們要學著用更新佇列與函式更新,完成「一次加三杯」(一鍵三連?)

參考資料


上一篇
Day 10 : 按部就班,拆解 React 更新畫面的過程
下一篇
Day 12 : 接二連三,連續更新 state
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言