React useOptimistic

useOptimistic 是一個讓你在非同步操作進行中顯示不同狀態的 Hook。它實現了「樂觀更新」(Optimistic Update)模式,讓使用者介面可以立即反應,不需要等待伺服器回應。

什麼是樂觀更新?

樂觀更新是一種 UI 模式:

  1. 使用者執行操作(如按讚)
  2. 立即更新 UI(假設操作會成功)
  3. 在背景發送請求到伺服器
  4. 如果成功,保持 UI 狀態
  5. 如果失敗,回滾到原本的狀態

這讓應用程式感覺更快速、更即時。

基本語法

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>
  )
}

使用時機

適合使用樂觀更新的場景:

  • 按讚/收藏:使用者預期立即看到反應
  • 新增/刪除項目:列表操作
  • 編輯內容:即時儲存
  • 切換開關:狀態切換

不適合使用的場景:

  • 金融交易:需要確認成功再顯示
  • 重要的不可逆操作:刪除帳號等
  • 需要伺服器驗證:庫存檢查等