React 的 Optimistic UI:故意說謊的介面
React 的 Optimistic UI:故意說謊的介面
在講微互動的那篇文章裡,我提過 Doherty Threshold——如果系統能在少於 400 毫秒內回應,用戶就會把它感知為「即時」。微互動的做法,是在伺服器回應回來之前,用按鈕狀態的動畫先把這個落差填補起來。Optimistic UI 再走前一步:它不只是把等待的過程用動畫呈現出來,而是預先假設這個動作一定會成功,直接展示最終結果——「讚好」按鈕裡的心心圖示立即填滿,數字馬上加一,儘管送去伺服器的 request 才剛剛出發。介面告訴用戶一件此刻還不是事實的事情——並且假定它很快就會成真。
只要這個假設是對的,這種做法就運作得非常好——而在實務上,對「讚好」、「加入最愛」或「標記為已讀」這類動作來說,絕大多數情況下這個假設確實成立。問題出在大部分實作嘗試手動處理的地方:點擊時 setState,出錯時在 catch 裡反向 setState。單獨測試、只點一次的話,看起來完全沒問題。但當用戶連續快速點擊兩次按鈕時,事情就會亂套——這正好對應到微互動那篇文章裡 Saffer 模型的第四個元素(循環與模式),只是這一次,出錯不再只是美觀上的小瑕疵,而是會導致實際上不一致的狀態。
為甚麼手寫的 rollback,兩次點擊就會出事
想像一個最直覺的手寫實作:點擊時立即在本地狀態切換 liked,發出 request,然後在 catch 裡把 liked 還原成點擊前的值——這個值在送出 request 之前,就已經先存進一個變數裡。
jsx
// 天真的 rollback——單次點擊沒問題,兩次點擊就出事const handleClick = async () => {const previousLiked = liked; // 點擊前那一刻凍結下來的狀態快照setLiked(!liked);try {await toggleLike(postId, !liked);} catch {setLiked(previousLiked); // 還原成「這一次」點擊之前的狀態}};
會出事的情境是這樣的:用戶點擊「讚好」(request A 開始,previousLiked = false),100 毫秒後點擊「取消讚好」(request B 開始,previousLiked = true,因為本地狀態這時已經變成了 true)。如果 request A 失敗了,而 request B 這時還在進行中、或者已經成功了,request A 的 catch 就會把狀態還原成 false——蓋掉了用戶有意識地做出的、而且可能已經成功的 B 動作的結果。一個 request 的 rollback,抹掉了另一個 request 的結果,因為兩者操作的是同一個共用的狀態變數,彼此完全不知道對方的存在。這正是那種在快速手動測試裡不會出現、卻會在正式環境裡冒出來的錯誤——當真實用戶點擊的速度,比開發者寫程式碼時想像的更快。
useOptimistic 如何從結構上、而不是靠補丁避開這個問題
React 的 useOptimistic 不是把 setState 加 try/catch 包裝得比較方便而已。它是一個不同的概念模型:不是保留一個可變的狀態欄位,讓你手動切換、手動還原,useOptimistic 會根據目前真實的狀態,即時計算出一層暫時的覆蓋值,這層覆蓋值只在該次操作(transition)進行期間存在。當操作結束時——不管成功還是失敗——這層覆蓋值會自己消失,露出那一刻真正的狀態。你不用寫 rollback 的程式碼。Rollback 是暫時覆蓋值消失後自然而然的結果,而不是一條你可能忘記處理、或者處理錯誤的獨立程式碼路徑。
jsx
"use client";import { useOptimistic, useTransition } from "react";import { toggleLikeAction } from "./actions";const LikeButton = ({ postId, liked, likeCount }) => {const [isPending, startTransition] = useTransition();const [optimistic, setOptimistic] = useOptimistic({ liked, likeCount },(state, nextLiked) => ({liked: nextLiked,likeCount: state.likeCount + (nextLiked ? 1 : -1),}),);const handleClick = () => {const nextLiked = !optimistic.liked;startTransition(async () => {setOptimistic(nextLiked);await toggleLikeAction(postId, nextLiked);// 這裡不需要在 catch 裡手寫 rollback——如果 action 拋出錯誤,// transition 結束後 React 一樣會捨棄這層覆蓋值,// 露出 props 傳進來的真實 "liked"。});};return (<buttontype="button"onClick={handleClick}aria-pressed={optimistic.liked}className={isPending ? "like-button--pending" : ""}><span aria-hidden="true">{optimistic.liked ? "♥" : "♡"}</span>{optimistic.likeCount}</button>);};export default LikeButton;
跟手寫版本相比,關鍵的分別在於:useOptimistic 計算出來的覆蓋值,是根據作為第一個參數傳進去的 state——也就是來自 props 的目前、真實狀態(通常來自 Server Action 和 revalidatePath,就跟 Server Actions 那篇文章講的一樣)——而不是根據一份另外用本地變數手動記下來的、凍結的快照。當用戶連續點擊兩次時,每一次 setOptimistic 呼叫,都是根據當下的狀態去計算新的覆蓋值,所以第二次點擊不可能被第一次遲來的 rollback 意外蓋掉——因為根本不存在一條獨立的「還原成記住的那個值」路徑,可以搞錯到底指的是時間軸上哪一刻。
甚麼時候 optimistic UI 是好主意,甚麼時候是危險的謊言
不是每一種動作都適合用這個模式,這也不是品味問題,而是假設錯誤時所帶來的後果問題。「讚好」、「加入最愛」、「標記為已讀」——後果輕微、容易復原,而且幾乎每次都會成功。付款、不可逆的刪除帳戶操作、把訊息傳送給另一個人——在這些情況下,在事情真正發生之前就樂觀地顯示成功,是有風險的:用戶會根據一個之後可能證實是假的訊息,去做下一步決定,而幾秒鐘後才把這個「謊言」收回,遠比一個單純多轉一會兒的 spinner 更令人困惑。
即使在 optimistic UI 確實合理的地方,如果 rollback 悄無聲息、沒有任何提示,就會重現它原本想解決的那個感知問題。用戶看到心心圖示填滿了,覺得事情已經完結,把注意力轉移到別的地方——過一會兒,心心又悄悄變回空的,沒有任何解釋。這正是 Saffer 模型裡「缺少循環與模式」的同一個錯誤,只是在時間上延後發生:成功有 feedback,失敗卻沒有。解決方法跟無障礙設計那篇文章裡講的是同一件工具——在失敗訊息上用 aria-live="polite" 或 role="alert",讓狀態的還原,對任何當下沒有正好盯著那個按鈕的人來說,不論在視覺上還是聲音上都不會是悄悄發生的。
常見陷阱與最佳實踐
setOptimistic必須在 transition 裡面呼叫。 在startTransition(或者傳給useActionState/<form action>的 action)之外,useOptimistic不會按預期運作——這個機制跟特定 transition 的生命週期是密不可分的,不是隨便一個渲染的時間點都行。- 傳給
useOptimistic的更新函式,必須是純函式而且成本低廉。 它會在渲染期間同步執行,用來計算覆蓋值——在這裡放複雜運算或副作用,是類別上的錯誤,不只是效能問題。 - 不要悄悄地隱藏失敗。 沒有任何訊息的靜默 rollback,比完全不做 optimistic update 更糟——用戶先得到一個假的確認,然後完全沒有得到任何「事情其實出錯了」的資訊。
- 把這個模式留給後果輕微、可復原的動作。 在假設錯誤代價高昂的地方(付款、不可逆的操作),比較好的選擇是用一個普通的載入狀態,事後再清楚確認結果,而不是一個預先猜測結果的介面。
總結
Optimistic UI 不是一種動畫上的花招——它是有意識地假設一個動作會成功,並在伺服器確認之前,就把這個假設展示給用戶看。用 setState 加 catch 手寫實作,在沒有人點擊得比開發者預想中快之前都行得通——一旦有人點得更快,一次操作的 rollback 就可能蓋掉另一次操作的結果,因為兩者共用同一份凍結的狀態快照。useOptimistic 從結構上消除了這一整類錯誤:覆蓋值永遠是根據目前真實的狀態計算出來的,而不是根據另外記住的一個值,所以 rollback 不是一段你要自己寫、也可能寫錯的程式碼——它是暫時性假設消失後自然而然的結果。剩下唯一一個沒有任何 API 能替你做的決定:這個動作是否足夠輕微、足夠容易復原,值得就算只有片刻,也要為它撒這個謊。