這是由 Sentry 贊助的 DEV's Summer Bug Smash: Smash Stories 投稿。

我最初在 7 月 8 日分享了這個除錯故事。Bug Smash 公告一出,我立刻就想到它,因為當時沒有任何東西當掉,沒有任何錯誤被記錄,但應用程式就是不對。

設定背景

一切都運作正常。我在待辦清單上接上了 useOptimistic,勾選框在你點下去的瞬間就翻轉,沒有載入轉圈,也沒有半秒延遲,完全就是我想要的感覺。我自己演示了十幾次,然後就繼續往下做了。

在樂觀更新後,發票完成勾選框被勾選並加上刪除線

這就是陷阱。樂觀式 UI 的設計目的,就是讓它立刻看起來正確。但「看起來正確」和「真的正確」不是同一件事。

我停止相信 Demo 的那一刻

接著我做了你在真的要上線前應該做的事。我超快地連點了五次,就像一個沒有耐心的真實使用者會做的那樣。

勾選框在畫面上看起來一切正常,但資料庫不是。五個重疊的請求已經送出,每個請求都在切換同一個布林值,最後依照當天網路實際到達的順序落地。沒有任何東西崩潰,也沒有任何錯誤被記錄。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)}
/>

一次只處理一個專案,作用範圍限於該列,不是把整個清單全域鎖住。現在對同一個勾選框第二次點擊會直接沒反應,而不會再送出第二個請求,跟第一個請求在資料庫裡賽跑。

實際上看起來就是這樣:五次快速點擊,只有一個請求。

瀏覽器 Network 分頁在五次快速點擊後只顯示一個 POST 請求

有一則留言讓我想得更深入

原文貼出後,Nazar Boyko 留了一則讓我一直記著的留言。

他指出了一件我自己沒有真正分開思考的事。停用勾選框,只能阻擋透過那個特定 input 觸發的點擊。如果同一個切換動作還可能透過其他方式觸發,例如鍵盤快捷鍵,或是應用程式中的其他地方,那麼 disabled 屬性就只是一層保護。真正的防線是以 id 為鍵的 pending 狀態,因為它可以阻止同一個操作再次開始,不管它是怎麼被觸發的。

他也提到,rollback(回復)那段說明是最讓他豁然開朗的部分。大多數範例都會在 catch 區塊裡手動把狀態切回去,卻沒有說明原因。當我意識到像這種單純的 toggle 其實不需要那個額外步驟時,這也是我在研究 useOptimistic 時學到的最大收穫之一,所以聽到它也幫助了別人,感覺很不錯。

這也是我很喜歡公開寫技術文章的原因之一。有時候別人不是來幫你找程式碼裡的 bug,而是幫你把概念講得更清楚,或從不同角度思考。

讓我鑽進兔子洞的 rollback 問題

我在處理這部分時,也去找了樂觀式更新失敗時該怎麼正確回復,因為很顯然,如果切換失敗,勾選框就應該跳回它原本真正的狀態。

我找到的幾乎每個範例都是這樣:

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 錯誤。

顯示 Next.js 16 中 revalidateTag 需要兩個參數的 TypeScript 錯誤

對我來說有什麼改變

以前我示範樂觀式 UI,只會點一次。

現在我會故意想辦法把它弄壞。五次快速點擊、慢速網路、重複操作。

競態條件很少會自己大聲宣告存在。它們不會丟出例外,也不會把你的 console 照亮。它們只是靜靜等待一個比你在開發期間更沒耐心的使用者出現。

那個勾選框每次展示時看起來都完美無瑕,但 bug 一直都在。它只是等著我像真正的使用者那樣去測試它而已。

如果你想看完整流程,包括 rollback 模式、快取決策,以及其餘實作細節,我都整理在這裡: shubhra.dev/tutorials/nextjs-16-useoptimistic-rollback-pattern


原文出處:https://dev.to/shubhradev/the-optimistic-ui-race-condition-that-only-showed-up-on-the-fifth-click-5a55


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝10   💬2   ❤️5
268
🥈
我愛JS
📝2   💬6   ❤️3
112
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登