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。
接續看增加杯數的處理函式,在更新前後各加一行 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 毫秒。會先安排函式,目前的程式可以繼續執行,頁面也可以操作,但實際執行時間也可能晚於三秒。
假設紅茶是 1 杯時按下回報,接著在回報出現前按增加到 3 杯。這時畫面已經變了,延遲訊息卻仍會回報 1。
原因是裡面的函式仍能讀取建立時所在作用域的變數,這是 JavaScript 的 (closure)。這個函式是連到杯數為 1 的那次元件執行。之後元件再次執行,會有另一份 quantity,不會替換掉已安排的函式所使用的那一份。
closure 本身不代表把所有變數都複製起來,在這個範例裡,是每次渲染有各自的 quantity,而更新 state 也沒有改寫舊的區域變數。
因此,等久一點,不會讓舊回呼自動改用新一次渲染的 state。 這也說明為什麼不能只用「更新是非同步的」來解釋讀到舊數值的原因。
現在知道同一次事件裡的 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 杯開始,按一次「加一杯」:

前兩行來自同一次事件,讀到的杯數都是 0。第三行已經是新的元件執行,拿到 1,畫面也會顯示紅茶 1 杯、小計 30 元。
| LOG | 杯數 | 說明 |
|---|---|---|
| 更新前的事件 Log | 0 |
按下按鈕時使用的快照 |
| 呼叫更新後的事件 Log | 0 |
仍是同一次處理函式的快照 |
| 新一次渲染與更新後的畫面 | 1 |
React 用新的 state 計算 JSX,再提交到 DOM |
重新載入首頁,先把紅茶加到 1 杯,清空 Console,按「3 秒後回報杯數」,接著在回報出現前按兩次「加一杯」。

首先看到預約訊息:
[事件] 預約回報 紅茶 1
加杯後,畫面變成 3 杯、小計 90 元。等回呼執行,預期出現:
[延遲] 按下回報時的杯數 紅茶 1
畫面上的 3 和回報的 1,各自描述不同時間點。前者是後來更新的杯數,後者是安排回報時的快照。
再換一個操作確認,現在紅茶是 3 杯,按回報後清空。

清空杯數後,回報仍讀到 3,但畫面跳回 0 杯,因為這個回呼只印出資料,沒有呼叫更新函式,所以讀到舊快照,不等於把 state 還原。
下面片段也是錯誤的認為等候後 quantity 就會更新。
async function handleAdd() {
await setQuantity(quantity + 1);
console.log("以為等待後就會得到新杯數", quantity);
}
但 setQuantity 不回傳代表更新完成的 Promise,這個 await 不能拿來等待 React 提交完成,也不會把這裡的 quantity 換成下一次渲染的值。
如果想真正觀察到變更後的資料,建立一個新的區域變數來處理相同的計算,例如前面提到的 nextQuantity 做法就可以囉。
今天範例其實還有一個問題:連續三次 setQuantity(quantity + 1) 在同一個事件中,增加只有 1 杯,等明天我們要學著用更新佇列與函式更新,完成「一次加三杯」(一鍵三連?)