React useOptimistic
useOptimistic 是一個讓你在非同步操作進行中顯示不同狀態的 Hook。它實現了「樂觀更新」(Optimistic Update)模式,讓使用者介面可以立即反應,不需要等待伺服器回應。
什麼是樂觀更新?
樂觀更新是一種 UI 模式:
- 使用者執行操作(如按讚)
- 立即更新 UI(假設操作會成功)
- 在背景發送請求到伺服器
- 如果成功,保持 UI 狀態
- 如果失敗,回滾到原本的狀態
這讓應用程式感覺更快速、更即時。
基本語法
import { useOptimistic } from 'react'
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn)
state:原始狀態值updateFn:接收當前狀態和樂觀值,返回新狀態的函式optimisticState:要渲染的樂觀狀態addOptimistic:觸發樂觀更新的函式
基本範例:按讚功能
import { useOptimistic, useState } from 'react'
function LikeButton({ initialLikes, postId }) {
const [likes, setLikes] = useState(initialLikes)
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(currentLikes, newLike) => currentLikes + newLike
)
async function handleLike() {
// 立即更新 UI(樂觀更新)
addOptimisticLike(1)
try {
// 發送請求到伺服器
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
})
const data = await response.json()
// 用伺服器回傳的真實數據更新
setLikes(data.likes)
} catch (error) {
// 如果失敗,狀態會自動回滾
console.error('按讚失敗')
}
}
return <button onClick={handleLike}>❤️ {optimisticLikes}</button>
}
搭配 Form Actions 使用
useOptimistic 常與 Form Actions 搭配使用:
import { useOptimistic } from 'react'
import { useFormStatus } from 'react-dom'
function TodoList({ todos, addTodoAction }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (currentTodos, newTodoText) => [
...currentTodos,
{
id: crypto.randomUUID(),
text: newTodoText,
completed: false,
pending: true, // 標記為待處理
},
])
async function formAction(formData) {
const text = formData.get('todo')
// 樂觀更新
addOptimisticTodo(text)
// 實際發送請求
await addTodoAction(formData)
}
return (
<div>
<form action={formAction}>
<input type="text" name="todo" placeholder="新增待辦事項" />
<SubmitButton />
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
{todo.pending && ' (儲存中...)'}
</li>
))}
</ul>
</div>
)
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '新增中...' : '新增'}
</button>
)
}
完整範例:留言功能
import { useOptimistic, useState } from 'react'
function CommentSection({ postId, initialComments }) {
const [comments, setComments] = useState(initialComments)
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(currentComments, newComment) => [
...currentComments,
{
...newComment,
sending: true,
},
]
)
async function handleSubmit(formData) {
const text = formData.get('comment')
const newComment = {
id: `temp-${Date.now()}`,
text,
author: '我',
createdAt: new Date().toISOString(),
}
// 樂觀更新
addOptimisticComment(newComment)
try {
// 發送到伺服器
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
if (!response.ok) throw new Error('提交失敗')
const savedComment = await response.json()
// 用伺服器回傳的資料更新
setComments((prev) => [...prev.filter((c) => c.id !== newComment.id), savedComment])
} catch (error) {
// 失敗時狀態會自動回滾
alert('留言失敗,請重試')
}
}
return (
<div className="comment-section">
<h3>留言</h3>
<form action={handleSubmit}>
<textarea name="comment" placeholder="寫下你的留言..." required />
<button type="submit">送出留言</button>
</form>
<ul className="comments">
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.sending ? 'sending' : ''}>
<div className="comment-header">
<strong>{comment.author}</strong>
<time>{formatDate(comment.createdAt)}</time>
</div>
<p>{comment.text}</p>
{comment.sending && <span className="sending-indicator">發送中...</span>}
</li>
))}
</ul>
</div>
)
}
function formatDate(dateString) {
return new Date(dateString).toLocaleString()
}
實際範例:購物車
import { useOptimistic, useState } from 'react'
function ShoppingCart({ initialItems }) {
const [cartItems, setCartItems] = useState(initialItems)
const [optimisticItems, updateOptimisticItems] = useOptimistic(
cartItems,
(currentItems, action) => {
switch (action.type) {
case 'UPDATE_QUANTITY':
return currentItems.map((item) =>
item.id === action.id ? { ...item, quantity: action.quantity, updating: true } : item
)
case 'REMOVE':
return currentItems.filter((item) => item.id !== action.id)
default:
return currentItems
}
}
)
async function updateQuantity(itemId, newQuantity) {
// 樂觀更新
updateOptimisticItems({ type: 'UPDATE_QUANTITY', id: itemId, quantity: newQuantity })
try {
const response = await fetch(`/api/cart/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity: newQuantity }),
})
const updatedItem = await response.json()
setCartItems((prev) => prev.map((item) => (item.id === itemId ? updatedItem : item)))
} catch (error) {
alert('更新失敗')
}
}
async function removeItem(itemId) {
// 樂觀更新
updateOptimisticItems({ type: 'REMOVE', id: itemId })
try {
await fetch(`/api/cart/${itemId}`, { method: 'DELETE' })
setCartItems((prev) => prev.filter((item) => item.id !== itemId))
} catch (error) {
alert('刪除失敗')
}
}
const total = optimisticItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
return (
<div className="shopping-cart">
<h2>購物車</h2>
{optimisticItems.length === 0 ? (
<p>購物車是空的</p>
) : (
<>
<ul>
{optimisticItems.map((item) => (
<li key={item.id} style={{ opacity: item.updating ? 0.7 : 1 }}>
<span>{item.name}</span>
<span>${item.price}</span>
<select
value={item.quantity}
onChange={(e) => updateQuantity(item.id, Number(e.target.value))}
disabled={item.updating}
>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<button onClick={() => removeItem(item.id)} disabled={item.updating}>
移除
</button>
{item.updating && <span>更新中...</span>}
</li>
))}
</ul>
<p className="total">總計: ${total}</p>
</>
)}
</div>
)
}
使用時機
適合使用樂觀更新的場景:
- 按讚/收藏:使用者預期立即看到反應
- 新增/刪除項目:列表操作
- 編輯內容:即時儲存
- 切換開關:狀態切換
不適合使用的場景:
- 金融交易:需要確認成功再顯示
- 重要的不可逆操作:刪除帳號等
- 需要伺服器驗證:庫存檢查等