useOptimistic,它跟 TanStack Query 的 onMutate 是同一件事的兩種寫法嗎?先更正我自己昨天講的一句話。
Day 20 的明天預告我寫了「能讓介面感覺快十倍」。實際量完以後,「快十倍」是錯的說法,它既低估也高估了這件事。今天第二節會用數字講清楚為什麼。
Day 20 講的是讀取:useQuery 怎麼靠 queryKey 避開競態,怎麼把結果存進自己的格子。
今天講寫入。寫入跟讀取有一個根本差異:
讀取的時候,畫面上本來就沒有東西,使用者對「等一下」有心理準備。
寫入的時候,畫面上已經有東西了,使用者的動作是「改它」,他期待它立刻變。
所以同樣是 600 毫秒的網路來回,發生在讀取時叫「載入中」,發生在寫入時叫「這個網站好卡」。
先把名詞定下來,後面整篇都用這兩個詞。
| 名稱 | 做法 | 畫面什麼時候變 |
|---|---|---|
| 悲觀更新(pessimistic update) | 送出請求,等伺服器回應,用回應改畫面 | 伺服器回來之後 |
| 樂觀更新(optimistic update) | 先改畫面,同時送出請求,回來之後用真實資料校正 | 使用者按下去的那一刻 |
「樂觀」這個詞的意思是:你賭它會成功,所以提前把成功的畫面畫上去。
賭輸了怎麼辦,就是第三節之後的全部內容。
情境是一份活動手冊,使用者按「發布」把 status 從 draft 改成 published。假伺服器固定 600 毫秒回應。
我在 day21-optimistic-update.js 裡量「使用者按下按鈕之後,畫面第一次真的改變是在第幾毫秒」。
使用者在第 0ms 按下「發布」
[伺服器 1ms] 收到請求 #1 發布 {"status":"published"}
[伺服器 602ms] 請求 #1 發布 成功,db 變成 status=published
[畫面 603ms] status=published
使用者在第 0ms 按下「發布」
[畫面 0ms] status=published ← 樂觀值,伺服器還沒回
[伺服器 0ms] 收到請求 #1 發布 {"status":"published"}
[伺服器 601ms] 請求 #1 發布 成功,db 變成 status=published
[畫面 601ms] status=published ← 伺服器回來了,用真實資料校正
悲觀更新:按下按鈕後 603ms 畫面才有反應
樂觀更新:按下按鈕後 0ms 畫面就有反應
差距:603ms
倍數:無法用倍數表達(分母是 0)
這就是為什麼「快十倍」是錯的說法。
分母是零,倍數這個詞在這裡根本沒有意義。如果伺服器要跑 3 秒,樂觀更新也一樣是 0 毫秒,難道就變成「快三十倍」嗎?倍數會隨著伺服器有多慢而浮動,但使用者的感受不是這樣運作的。
Jakob Nielsen 在 1993 年的 Usability Engineering 裡整理了三個反應時間界線,這三個數字到今天還在用:
| 界線 | 使用者的感受 |
|---|---|
| 0.1 秒以內 | 覺得是「系統即時反應」,不需要任何載入提示 |
| 1 秒以內 | 思緒不中斷,但已經感覺到在等 |
| 10 秒以內 | 注意力還在這個任務上,超過就會跑去做別的事 |
把量測結果放進去看:
樂觀更新做的事情是「跨過 100 毫秒那條線」,不是「把時間除以十」。
這個區別不只是講法精確而已,它會改變你的判斷:如果你的 API 本來就只要 60 毫秒,那你已經在第一區間了,樂觀更新對使用者沒有任何差別,你只是多寫了回滾的程式碼。判斷標準不是「能不能更快」,是「現在在哪一區間」。
樂觀更新最直覺的寫法長這樣:
cache = { ...cache, status: 'published' } // 先改畫面
screen.render(cache)
try {
const result = await server.patch({ status: 'published' })
cache = result
} catch (error) {
// 想回滾,但舊值早就被上面那行蓋掉了
}
白話講這段:cache = { ...cache, status: 'published' } 展開舊的 cache 再覆蓋 status,產生一個新物件指派回 cache。這一行執行完,原本那個舊物件就沒有任何變數指著它了,你想回滾也找不回來。
這是 Day 3「傳值與傳址」那篇的直接應用:{ ...cache } 是淺拷貝,它產生新的 reference,而 cache 這個變數被重新指向新的那一份。舊的那份不是被改掉,是被放生了。
實測結果:
【沒備份】
[畫面 0ms] status=published ← 樂觀值
[伺服器 601ms] 請求 #1 發布 失敗(500)
[程式 602ms] 想回滾,但手上沒有舊值,只能什麼都不做
畫面最後顯示 status=published,資料庫是 status=draft
【有備份】
const previous = cache ← 只差這一行
[畫面 0ms] status=published ← 樂觀值
[伺服器 601ms] 請求 #1 發布 失敗(500)
[畫面 602ms] status=draft ← 回滾
畫面最後顯示 status=draft,資料庫是 status=draft
沒備份的那個版本,最後留下的畫面是:
使用者以為自己發布成功了,其實資料庫從頭到尾都是草稿。
而且它不會自己修好。要等到使用者重新整理、切分頁回來觸發 refetch、或是下一次 invalidateQueries,畫面才會變回真相。在那之前,你的前端在對使用者說謊。
這就是為什麼 TanStack Query 的 onMutate 一定要 return { previousTodos }:
onMutate: async (newTodo, context) => {
await context.client.cancelQueries({ queryKey: ['todos'] })
const previousTodos = context.client.getQueryData(['todos'])
context.client.setQueryData(['todos'], (old) => [...old, newTodo])
return { previousTodos } // ← 這一行就是上面的 const previous = cache
},
onError: (err, newTodo, onMutateResult, context) => {
context.client.setQueryData(['todos'], onMutateResult.previousTodos)
},
onSettled: () => context.client.invalidateQueries({ queryKey: ['todos'] })
白話講這四行的拋接關係:
cancelQueries 先把正在飛的 refetch 取消掉,否則那個舊的 GET 回來會把你剛寫進去的樂觀值蓋掉getQueryData 把此刻快取裡的值讀出來存成 previousTodos
setQueryData 把樂觀值寫進快取,畫面立刻變onMutate 回傳的物件會被 TanStack Query 收好,等一下原封不動交給 onError,這就是備份的傳遞路徑這裡有一個很多人誤解的點要先講清楚:
cancelQueries擋的是「查詢」跟「修改」打架,不是「修改」跟「修改」打架。
它取消的是 in-flight 的 useQuery refetch。兩個 useMutation 同時在飛的時候,cancelQueries 幫不上忙——那正是下一節的主題。
到目前為止都還很單純,因為畫面上只有一個操作在飛。
現實不是這樣。使用者按了「發布」,等了 30 毫秒沒反應,順手又改了標題。現在有兩個 mutation 同時在空中。
| 操作 | 伺服器耗時 | 結果 | |
|---|---|---|---|
| A | 按「發布」(改 status) |
600 ms | 失敗(500) |
| B | 改標題(改 title) |
150 ms | 成功 |
兩邊都照教科書寫:onMutate 備份整份快取,onError 寫回整份快取。這是正確的單一 mutation 寫法。
[A 0ms] onMutate 備份 status=draft title=場地規範
[畫面 0ms] status=published title=場地規範 ← A 的樂觀值
[B 31ms] onMutate 備份 status=published title=場地規範
[畫面 31ms] status=published title=場地規範 v2 ← B 的樂觀值
[伺服器 182ms] 請求 #2 B 改標題 成功,db 變成 status=draft title=場地規範 v2
[畫面 182ms] status=draft title=場地規範 v2 ← B 成功,用伺服器回應校正
[伺服器 601ms] 請求 #1 A 發布 失敗(500)
[畫面 601ms] status=draft title=場地規範 ← A 失敗,寫回 A 的快照
畫面最後顯示:status=draft title=場地規範
資料庫真相: status=draft title=場地規範 v2
一致嗎:不一致
a. A 的 onMutate 備份了「發布之前」的整份資料 → { draft, 場地規範 }
b. B 的 onMutate 備份了「B 開始時」的整份資料 → 裡面含有 A 的樂觀值 { published, 場地規範 }
c. B 成功,用伺服器回應蓋掉整份快取 → A 的樂觀值 published 被抹掉,畫面在第 182 毫秒閃回草稿
d. A 失敗,把 A 的快照寫回去 → 連 B 已經寫進資料庫的新標題一起抹掉
使用者看到的是:標題改成 v2 了,然後自己變回去了。而資料庫裡明明存的是 v2。
回滾寫回的是「那個 mutation 開始時的整個世界」,不是「那個 mutation 改的那一個欄位」。
單一操作看不出差別,因為「整個世界」剛好就等於「你那個欄位」。
一旦併發,快照就會把別人的成功結果一起裝進去,然後在回滾時一起還原掉。
這也解釋了為什麼這個 bug 在本機幾乎不會出現——跟 Day 20 的競態條件同一個理由:本機的延遲太小,兩個 mutation 撞在一起的機率太低。
回滾的時候只還原自己動過的那幾個 key,其他欄位維持現狀。
function 樂觀補丁(patch) {
const backup = {}
for (const key of Object.keys(patch)) backup[key] = cache[key]
cache = { ...cache, ...patch }
return {
rollback() {
cache = { ...cache, ...backup } // 只還原自己那幾個 key
},
}
}
白話講這段:Object.keys(patch) 拿出這次要改的欄位名單,只備份名單上的那幾個,不備份整份。回滾時也用展開語法把備份疊回現在的 cache 上,所以別人在這段期間改的欄位完全不受影響。
實測:
[畫面 181ms] status=published title=場地規範 v2 ← B 成功,不覆蓋其他欄位
[畫面 601ms] status=draft title=場地規範 v2 ← A 失敗,只還原 status
畫面: status=draft title=場地規範 v2
資料庫:status=draft title=場地規範 v2
一致嗎:一致
代價:成功的時候不能直接用整份伺服器回應蓋快取。伺服器自己算出來的欄位(updatedAt、version、後端算的 slug)你就拿不到了。
回滾只是過渡畫面,最後一律重新去問伺服器。對應的就是 onSettled 裡那行 invalidateQueries。
但有一個細節要處理:如果每個 mutation 落地都 refetch 一次,兩個 mutation 就會打兩次 GET,而且先回來的那次可能是舊資料。所以要數還有幾個在飛:
let 飛在空中的數量 = 0
async function 收尾() {
飛在空中的數量 -= 1
if (飛在空中的數量 > 0) return // 還有人在飛,先不 refetch
const fresh = await server.get()
cache = fresh
}
實測:
[畫面 182ms] status=draft title=場地規範 v2 ← B 成功
[收尾 182ms] 還有 1 個 mutation 在飛,先不 refetch
[畫面 601ms] status=draft title=場地規範 ← A 失敗,回滾(只是過渡畫面)
[收尾 601ms] 全部落地,向伺服器重新拿一次
[畫面 701ms] status=draft title=場地規範 v2 ← refetch 的結果,這才是最終答案
一致嗎:一致
代價:多一次 GET,而且要等它回來。注意第 601 毫秒到第 701 毫秒之間,畫面會先閃一下錯的回滾值,才被 refetch 校正。使用者看得到那一閃。
(TanStack Query 內建的 mutation scope 與 isMutating 可以達到同樣的效果,這裡手寫是為了讓「為什麼要數」這件事看得見。)
| 修法一:欄位級回滾 | 修法二:伺服器收尾 | |
|---|---|---|
| 最終一致 | 一致 | 一致 |
| 額外請求 | 0 | 每批 mutation 多一次 GET |
| 中間會不會閃錯的值 | 不會 | 會閃一下 |
| 拿不拿得到伺服器算的欄位 | 拿不到 | 拿得到 |
| 實作複雜度 | 要自己管 patch 的 key | 要自己數在飛的數量 |
沒有哪一個是標準答案。判準是:你的資料裡有沒有「伺服器才算得出來的欄位」。 有的話只能走修法二,沒有的話修法一更乾淨。
前面兩種修法都在處理同一個結構問題:樂觀值被寫進了真相裡,所以要想辦法把它挖出來。
那如果從一開始就不要寫進去呢?
function createOptimisticStore(initial) {
let base = { ...initial } // 伺服器真相
const overlays = new Map() // 還在飛的樂觀值
let nextId = 1
const view = () => {
let result = { ...base }
for (const patch of overlays.values()) result = { ...result, ...patch }
return result
}
return {
get value() { return view() },
setBase(next) { base = { ...next } },
addOverlay(patch) {
const id = nextId++
overlays.set(id, patch)
return () => overlays.delete(id) // 回傳「拿掉這一層」的函式
},
}
}
白話講這段:base 是伺服器真相,overlays 是一疊還沒落地的樂觀值。畫面讀的 view() 是每次即時把所有層疊起來算出來的,不是存在某個變數裡的。addOverlay 回傳的那個函式是這一層的橡皮擦,呼叫它就只擦掉自己這一層。
用同一個併發劇本跑:
[畫面 0ms] status=published title=場地規範 ← 疊上 A 的樂觀層
[畫面 30ms] status=published title=場地規範 v2 ← 疊上 B 的樂觀層
[畫面 181ms] status=published title=場地規範 v2 ← 拿掉 B 的樂觀層(base 已更新)
[畫面 601ms] status=draft title=場地規範 v2 ← 拿掉 A 的樂觀層
一致嗎:一致
剩下幾層樂觀值:0
整段程式碼裡沒有出現 previous,沒有出現 rollback。
因為回滾在這個模型裡不是一個動作,而是「那一層自然消失」。A 失敗的時候不需要知道世界原本長什麼樣,它只要把自己那一層拿掉,剩下的 base 加上 B 的層自己就是正確答案。
useOptimistic 的模型const [optimisticState, addOptimistic] = useOptimistic(value, reducer)
React 官方文件對回傳值的描述是:
optimisticState:當前的樂觀狀態。在沒有 Action 進行中時,它等於value;有 Action 進行中時,它等於reducer算出來的狀態。
以及這句,正好對應上面那個「自然消失」:
「沒有額外的一次 render 來『清除』樂觀狀態。樂觀狀態與真實狀態會在 Transition 結束的那一次 render 裡收斂。」
這一句解釋了 useOptimistic 比第五節的修法二好在哪裡:修法二那個「先閃一下回滾值、再被 refetch 校正」的中間畫面,在這個模型裡不存在,因為拿掉疊加層跟更新 base 發生在同一次 render。
失敗的情況官方文件也寫得很明確:
「如果 Action 拋出錯誤,Transition 一樣結束,React 就用
value當下的值去 render。既然上層通常只在成功時更新value,失敗就代表value沒有變過,所以畫面會顯示樂觀更新之前的樣子。」
換句話說,useOptimistic 沒有提供回滾 API,是因為它的模型裡不需要回滾這個動作。
React 19 的
useOptimistic跟 TanStack Query 的onMutate是同一件事的兩種寫法嗎?
不是。它們是兩種不同的模型:
onMutate + setQueryData |
useOptimistic |
|
|---|---|---|
| 樂觀值放哪裡 | 寫進快取本體 | 疊在真相上面的一層 |
| 回滾怎麼做 | 把備份寫回去(要 previous) |
拿掉那一層(不需要備份) |
| 併發時會不會抹掉別人 | 會,除非做第五節的修法 | 結構上不會 |
| 多個元件看得到嗎 | 看得到(快取是共用的) | 只在宣告它的那棵子樹 |
| 前提 | 你有一個快取層 | 你有一個明確的「真相」來當 base |
值得注意的是,TanStack Query 官方文件現在把「Via the UI」列在「Via the Cache」前面,而 Via the UI 的做法正是讀 useMutation 的 variables 來畫暫時的 UI——那也是疊加層模型。官方的建議是:
「如果你只有一個地方需要顯示樂觀結果,用
variables直接更新 UI 是程式碼比較少、也比較容易推理的做法。」
兩邊都在往疊加層走。 差別只在 React 把它做進了框架,TanStack Query 把它留在 hook 的回傳值裡。
這個系列的慣例,每隔幾天要橫向看一次。樂觀更新這件事,各家的分歧不在 API,而在它被放在哪一層。
| 框架/工具 | 放在哪一層 | 模型 |
|---|---|---|
| React 19 | 框架本身(useOptimistic + Actions) |
疊加層 |
| TanStack Query(React/Vue/Svelte/Solid 都有) | 資料層 | 兩種都提供,文件現在推疊加層 |
| SvelteKit | 表單層(use:enhance) |
提交中的 FormData 就是那一層 |
| React Router/Remix | 路由層(useFetcher().formData) |
同上,比 useOptimistic 更早採用 |
看出共同點了嗎:後來的設計都把樂觀值放在「提交這個動作本身」上,而不是放進狀態容器裡。
理由就是第四節那個 bug。只要樂觀值跟真相住在同一個盒子裡,你就永遠要處理「誰的快照蓋掉誰」。把它掛在提交上,提交結束它自己就沒了。
(use:enhance 與 useFetcher 的細節我沒有在這個系列實作過,這一段是讀文件後的整理,不是實測。)
系列慣例,一定要有這一節。四種情況我不會做樂觀更新:
a. 你的 API 本來就在 100 毫秒以內
第二節那條線。已經在第一區間了,樂觀更新對使用者沒有任何差別,你只是多寫了回滾。先去量,不要先寫。
b. 失敗的代價很高
付款、下單、送出考卷、按「確認刪除」。這些操作先顯示成功再說抱歉,比讓使用者等 600 毫秒糟糕得多。 判準是:如果回滾發生在使用者已經關掉分頁之後,他會不會受到實質損失。
c. 伺服器回來的東西你猜不到
前端沒辦法自己算出正確的樂觀值的時候。例如送出訂單以後編號是後端發的、折扣是後端算的、排序位置由後端決定。你硬猜一個放上去,回來以後畫面會跳一下,那一跳比 loading 更難看。
d. 同一份資料有多個使用者同時在改
協作編輯、共用看板。這時候要的不是樂觀更新,是 CRDT 或 OT 那一套衝突解決機制。樂觀更新的前提是「只有我在改」,這個前提一破,回滾的語意就不成立了。
看到一段「先改畫面再送請求」的程式碼時,問四個問題:
| 問題 | 如果答案是「否」 |
|---|---|
| 我的 API 有超過 100 毫秒嗎? | 不需要樂觀更新,先去量 |
| 我有把舊值存起來嗎? | 失敗時畫面會留在騙人的狀態,而且不會自己修好 |
| 我回滾的是「我改的那幾個欄位」嗎? | 併發時會抹掉別人的成功結果 |
| 我的樂觀值跟真相是分開存的嗎? | 你遲早要寫第五節那兩種修法其中之一 |
還有一個更根本的心態:
樂觀更新不是效能優化,是一個關於「畫面可以暫時不等於真相」的設計決定。
效能優化做錯了,是慢;這件事做錯了,是畫面在說謊。所以它的難點從來不在怎麼提前改畫面,而在怎麼讓謊言一定會被收回。
Day 22 講 React 19 的 Actions 與 useActionState:今天的 useOptimistic 其實只是 Actions 這一整套裡的一塊,明天把 <form action={fn}>、useActionState、useFormStatus 跟 pending 狀態一起講完,順便回答「React 為什麼把表單交回給瀏覽器」。
存成 day21-optimistic-update.js,執行 node day21-optimistic-update.js 可以重跑本文全部五段實測。純 Node.js 不需要任何套件,也不需要 React——樂觀更新與回滾是非同步本身的問題,跟框架無關,這一點跟 Day 20 的競態條件一樣。
檔案分成五段,對應本文的第二到第六節:
| Part | 內容 | 對應章節 |
|---|---|---|
| 1 | 悲觀與樂觀的時間差,以及 Nielsen 的三條線 | 第二節 |
| 2 | 失敗回滾:有備份與沒備份 | 第三節 |
| 3 | 兩個 mutation 同時在飛,整份快照回滾出事 | 第四節 |
| 4 | 兩種修法:欄位級回滾、伺服器收尾 | 第五節 |
| 5 | 疊加層模型,20 行的樂觀更新引擎 | 第六節 |
核心的那 20 行(Part 5)直接貼在這裡,其餘請看檔案:
function createOptimisticStore(initial) {
let base = { ...initial } // 伺服器真相
const overlays = new Map() // 還在飛的樂觀值,key 是 mutation id
let nextId = 1
const view = () => {
let result = { ...base }
for (const patch of overlays.values()) result = { ...result, ...patch }
return result
}
return {
get value() { return view() },
setBase(next) { base = { ...next } },
addOverlay(patch) {
const id = nextId++
overlays.set(id, patch)
return () => overlays.delete(id)
},
get overlayCount() { return overlays.size },
}
}
延續這個系列的做法,把內容分類標示。
一、有正式出處的部分
| 內容 | 出處 |
|---|---|
useOptimistic 的簽名、回傳值、以及「樂觀狀態只在 Action 進行中 render」 |
React 官方文件:https://react.dev/reference/react/useOptimistic |
| 「沒有額外的一次 render 來清除樂觀狀態,兩者在 Transition 結束的同一次 render 收斂」 | 同上,Reference 段落 |
「Action 拋錯時 Transition 一樣結束,畫面回到 value 當下的值」 |
同上 |
onMutate / onError / onSettled 的完整回滾範例,以及 cancelQueries、getQueryData、setQueryData 的角色 |
TanStack Query 官方文件:https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates |
「只有一個地方要顯示樂觀結果時,用 variables 更新 UI 程式碼較少也較好推理」 |
同上,Via the UI 段落 |
| 0.1 秒/1 秒/10 秒三個反應時間界線 | Jakob Nielsen, Usability Engineering(1993),Nielsen Norman Group 線上版:https://www.nngroup.com/articles/response-times-3-important-limits/ |
SvelteKit use:enhance 在提交期間可取用 FormData |
Svelte 官方教學:https://svelte.dev/tutorial/kit/customizing-use-enhance |
二、我實際跑出來的部分
第二到第六節所有的毫秒數與時間軸,全部由 day21-optimistic-update.js 實測產生(Node.js v22.22.2,2026-09-22 執行),可以重跑驗證。包含:
published 而資料庫是 draft
場地規範/資料庫 場地規範 v2」的不一致Part 5 的 createOptimisticStore 是我寫的極簡模型,不是 React 的原始碼。 它只示範「樂觀值是疊加層而不是寫進真相」這一個設計概念,useOptimistic 真實的實作牽涉 Transition、排程與並行 render,複雜得多。
三、我自己的整理與判斷(沒有外部出處)
四、我沒有實作驗證的部分
第七節表格裡的 SvelteKit use:enhance 與 React Router/Remix useFetcher().formData,我是讀文件整理的,沒有在這個系列裡實際寫過。如果你要照著用,請以各自的官方文件為準。
五、關於昨天那句「快十倍」
Day 20 的明天預告是先寫的,數字是今天才量的。量完發現倍數這個講法站不住腳(分母是 0),所以今天第二節直接更正。預告寫在實測之前是我流程上的疏忽,留著不刪是為了讓修正這件事本身留在系列裡。
(查閱日期:2026-09-22。程式碼實測於 Node.js v22.22.2)