這是由 Sentry 贊助的 DEV's Summer Bug Smash: Smash Stories 投稿。
我最初在 7 月 8 日分享了這個除錯故事。Bug Smash 公告一出,我立刻就想到它,因為當時沒有任何東西當掉,沒有任何錯誤被記錄,但應用程式就是不對。
一切都運作正常。我在待辦清單上接上了 useOptimistic,勾選框在你點下去的瞬間就翻轉,沒有載入轉圈,也沒有半秒延遲,完全就是我想要的感覺。我自己演示了十幾次,然後就繼續往下做了。

這就是陷阱。樂觀式 UI 的設計目的,就是讓它立刻看起來正確。但「看起來正確」和「真的正確」不是同一件事。
接著我做了你在真的要上線前應該做的事。我超快地連點了五次,就像一個沒有耐心的真實使用者會做的那樣。
勾選框在畫面上看起來一切正常,但資料庫不是。五個重疊的請求已經送出,每個請求都在切換同一個布林值,最後依照當天網路實際到達的順序落地。沒有任何東西崩潰,也沒有任何錯誤被記錄。UI 只是悄悄地不再和伺服器上實際發生的事情一致,而我光看畫面完全無法知道。
這種 bug 特別容易在樂觀式 UI 裡被忽略,因為樂觀式 UI 的核心目的就是讓它立刻看起來正確。
useOptimistic 特有的這跟表單送出時先停用提交按鈕是同一個道理,只是從「整個表單」改成「每一列」各自處理。追蹤哪個專案有請求正在進行,等請求結束前先禁用它。
const [pendingId, setPendingId] = useState<string | null>(null);
function handleToggle(id: string) {
setPendingId(id);
startTransition(async () => {
setOptimisticTask(id);
try {
await toggleTask(id);
} finally {
setPendingId(null);
}
});
}
<input
type="checkbox"
checked={task.completed}
disabled={pendingId === task.id}
onChange={() => handleToggle(task.id)}
/>
一次只處理一個專案,作用範圍限於該列,不是把整個清單全域鎖住。現在對同一個勾選框第二次點擊會直接沒反應,而不會再送出第二個請求,跟第一個請求在資料庫裡賽跑。
實際上看起來就是這樣:五次快速點擊,只有一個請求。

原文貼出後,Nazar Boyko 留了一則讓我一直記著的留言。
他指出了一件我自己沒有真正分開思考的事。停用勾選框,只能阻擋透過那個特定 input 觸發的點擊。如果同一個切換動作還可能透過其他方式觸發,例如鍵盤快捷鍵,或是應用程式中的其他地方,那麼 disabled 屬性就只是一層保護。真正的防線是以 id 為鍵的 pending 狀態,因為它可以阻止同一個操作再次開始,不管它是怎麼被觸發的。
他也提到,rollback(回復)那段說明是最讓他豁然開朗的部分。大多數範例都會在 catch 區塊裡手動把狀態切回去,卻沒有說明原因。當我意識到像這種單純的 toggle 其實不需要那個額外步驟時,這也是我在研究 useOptimistic 時學到的最大收穫之一,所以聽到它也幫助了別人,感覺很不錯。
這也是我很喜歡公開寫技術文章的原因之一。有時候別人不是來幫你找程式碼裡的 bug,而是幫你把概念講得更清楚,或從不同角度思考。
我在處理這部分時,也去找了樂觀式更新失敗時該怎麼正確回復,因為很顯然,如果切換失敗,勾選框就應該跳回它原本真正的狀態。
我找到的幾乎每個範例都是這樣:
try {
setOptimisticTask(id);
await toggleTask(id);
} catch {
setOptimisticTask(id); // 再切一次,把它「還原」
}
這樣確實可行。但它其實是在解決一個對像這種單純 toggle 來說根本不需要解決的問題。useOptimistic 不會給你第二份獨立、由你自己擁有的狀態。它提供的是一個暫時值,覆蓋在你傳入的原始狀態之上,而且只會存在於 transition 尚未結束的這段時間。當這個 transition 結束時,不管成功或失敗,React 都會丟掉這層暫時值,重新用真實狀態渲染。如果真實狀態沒有改變,因為請求失敗且也沒有重新抓取資料,那麼勾選框會自己回到原狀,不需要再額外 dispatch 一次。
不過有一種真正的例外。如果樂觀值是伺服器還完全沒確認過的東西,例如你新增一則留言,先用暫時 id 顯示,然後資料庫才分配真正的 id,那麼在你的基礎狀態裡根本沒有那個專案的前一版可以回退。插入失敗時,不是把某個旗標還原,而是必須刪掉一個只存在於 client 的東西。這是唯一一種你要自己保留新增內容的本地紀錄,並在 catch 區塊裡手動清掉它的情況。toggle 不會有這個問題,因為它切換的值本來就已經存在於伺服器上。
toggle 修好之後,下一個問題就是要如何在操作後正確讓快取失效。Next.js 16 提供了三種做法,選錯的話,不是讓使用者看到過期資料,就是讓網站上的每個頁面都必須為同一筆寫入一起阻塞。
對於一個使用者正盯著、並且當下剛點擊的勾選框,updateTag 才是正確選擇,因為它會立刻讓 tag 失效,讓使用者目前所在的頁面馬上反映自己的寫入。如果同一個任務數量也會供網站某處的側邊欄統計使用,而且沒有人在即時盯著,那麼對同一個 tag 使用 revalidateTag,則可以讓它稍後再追上。
如果你用的是較新的 Next.js 16 版本,有一件事值得注意:revalidateTag 現在需要第二個參數。
revalidateTag("tasks", "max"); // Next.js 16+ 建議做法
單獨使用 revalidateTag("tasks") 已被棄用,且會拋出 TypeScript 錯誤。

以前我示範樂觀式 UI,只會點一次。
現在我會故意想辦法把它弄壞。五次快速點擊、慢速網路、重複操作。
競態條件很少會自己大聲宣告存在。它們不會丟出例外,也不會把你的 console 照亮。它們只是靜靜等待一個比你在開發期間更沒耐心的使用者出現。
那個勾選框每次展示時看起來都完美無瑕,但 bug 一直都在。它只是等著我像真正的使用者那樣去測試它而已。
如果你想看完整流程,包括 rollback 模式、快取決策,以及其餘實作細節,我都整理在這裡: shubhra.dev/tutorials/nextjs-16-useoptimistic-rollback-pattern