按讚,訂閱,開啟小鈴噹,是三個不同的事件~
queue的串接與計算昨天我們有做了一個小示範,連續更新 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。
順便複習畫面更新流程,以 0 杯按一次增加事件,提出三個杯數來看,流程是:
按下按鈕
↓
同一次事件提出三個「把杯數設成 1」的更新
↓
事件處理結束,React 根據這批更新決定新畫面
↓
元件使用 quantity = 1 計算 JSX,提交後顯示 1 杯
同一次事件中 React 會收集這批更新,等事件處理程式執行完後再更新畫面,並不會每呼叫一次 setter,就渲染讓使用者看到中間結果,這就是批次處理(batching)。
快照說明三行為什麼都算出 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 內部是怎麼運作的。
回到 const [quantity, setQuantity] = useState(0)。
React 在初始化這個 Hook 時,會替它建立一個更新佇列(queue),並讓回傳的 setQuantity 對應到這個 queue。
換句話說,佇列由 React 建立與保存,而 setter 知道要把更新交到哪裡。
當每次呼叫 setQuantity(...),React 就建立一筆更新,用 action 保存我們傳入的數值或函式。前面連續呼叫三次,就產生三筆更新,依呼叫順序交給同一個 Hook 的 queue。
這些待處理更新會串成環狀單向鏈結串列,每筆更新是一個節點,箭頭表示「下一筆在哪裡」,最後一筆再接回第一筆,以 U1、U2、U3 表示依序加入的三筆更新,我們用簡化的圖來看:

React 記住尾端 U3,就能沿著尾端的連線找到開頭 U1。
接著看這個環怎麼一步步接起來:
| 加入的更新 | 串接後的樣子 | React 記住的尾端 |
|---|---|---|
第一筆 U1 |
U1 → U1 |
U1 |
第二筆 U2 |
U1 → U2 → U1 |
U2 |
第三筆 U3 |
U1 → U2 → U3 → U1 |
U3 |
只有第一筆時,讓它接回自己,就形成最小的一個環。之後每加入一筆,都是接到原本的尾端後面。
例如加入第三筆 U3 前,尾端是 U2,它原本接回開頭 U1。React 會依序做三件事:
U3 接回原本的開頭 U1。U2 原本接回 U1 的連線,改成接向 U3。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。
看過串接方式後,再把數值與函式放進同一批更新。例如原本是 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 |
數值更新會指定這一次的結果,函式更新則接續前面的計算,依序追下來,就能解釋為什麼同樣兩行,交換位置會得到不同的杯數。
接著把前面的兩種加三杯寫法與順序觀察,放進同一個點餐畫面,下面完整程式替換掉昨天的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 裡。
第一次點按"連寫三次"
第二次點按"連寫三次"
因為同一事件內的 quantity 不變,可以看到分次點按連寫三次,結果跟"加一杯"一樣。
第一次點按"一次加三杯"
第二次點按"一次加三杯"
效果不同了,因為我們用函式更新(updater)處理新增,每次都會加三杯。

我們沿用前面一次加三杯的執行結果,按下佇列 "順序測試" 後,可以看到先把計算結果重設為 0,再加一,最後顯示 1 杯。
前面的先清空再加一正常,接著來看錯誤的使用了箭頭函式,但函式讀取外層快照,沒有使用前一步的計算結果:
// 錯誤示範:計算仍讀取外層 quantity。
setQuantity(() => quantity + 1);
setQuantity(() => quantity + 1);
setQuantity(() => quantity + 1);
假設從 0 杯開始,三個函式都回傳外層的 0 + 1,雖然傳給 setter 的已經是函式,但沒有使用 React 傳進來的參數,計算也就沒有接續前一步的結果。

把計算改回 (prevQuantity) => prevQuantity + 1,就沒問題了。
光是這個 state 就理解好久,重點就是注意事件和渲染之間的關係,以及同一次事件中更新的 state 內容,才不會發生事件觸發沒反應,或是觸發了但結果異常的情況。
明天把杯數以外的飲料規格整理成物件,接著修改甜度與冰塊,看看更新物件時為什麼需要建立新資料。
但這還只是冰山一角...![]()