React 19 useActionState 完整教學:搭配 useOptimistic 與 Server Actions 打造流暢表單體驗
為什麼你需要 useActionState?
還記得以前用 React 處理表單提交的痛苦嗎?你得同時管理 useState 追蹤 loading 狀態、用 useTransition 包裝非同步操作、再寫一堆 try-catch 處理錯誤。光是一個簡單的「送出留言」功能,就要寫上 30 行樣板程式碼。
React 19 推出的 useActionState 就是為了解決這個問題。它的概念很像 useReducer,但專門為「有副作用的使用者操作」設計——你丟一個 action 函式進去,它幫你管理 pending 狀態、回傳結果、處理錯誤,一次搞定。如果你還沒跟上 React 19 的新功能,建議先看看React 19 新功能完整指南打好基礎。
useActionState 基本用法
先來看最簡單的用法。useActionState 接收兩個必要參數:一個 action 函式和初始狀態。
import { useActionState } from 'react';
async function submitComment(previousState, formData) {
const content = formData.get('content');
const res = await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!res.ok) {
return { error: '留言送出失敗,請稍後再試' };
}
return { success: true, message: '留言已送出!' };
}
export default function CommentForm() {
const [state, formAction, isPending] = useActionState(
submitComment,
{ error: null, success: false, message: '' }
);
return (
<form action={formAction}>
<textarea name="content" required />
<button type="submit" disabled={isPending}>
{isPending ? '送出中...' : '送出留言'}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
{state.success && <p className="text-green-500">{state.message}</p>}
</form>
);
}
注意幾個重點:action 函式的第一個參數是「上一次的狀態」(跟 useReducer 的 reducer 很像),第二個參數是 FormData。回傳值會成為新的 state。而 isPending 在 action 執行期間會自動變成 true,你再也不用手動管理 loading 狀態了。
搭配 Server Actions 使用
useActionState 真正強大的地方在於跟 Server Actions 的整合。你可以直接把伺服器端函式當作 action 傳入,React 會在 hydration 完成前就顯示伺服器回傳的結果。這在Next.js Server Actions 表單處理的場景中特別實用。
// app/actions.js
'use server';
export async function createTodo(previousState, formData) {
const title = formData.get('title');
if (!title || title.trim().length === 0) {
return { error: '待辦事項不能是空的啦!' };
}
try {
await db.todos.create({ data: { title } });
return { success: true, error: null };
} catch (e) {
return { error: '新增失敗,請再試一次' };
}
}
// app/components/TodoForm.jsx
'use client';
import { useActionState } from 'react';
import { createTodo } from '../actions';
export function TodoForm() {
const [state, formAction, isPending] = useActionState(
createTodo,
{ error: null, success: false }
);
return (
<form action={formAction}>
<input type="text" name="title" placeholder="今天要做什麼?" />
<button disabled={isPending}>
{isPending ? '新增中...' : '新增待辦'}
</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
這裡有個很酷的特性:如果你的表單需要在 JavaScript 載入前就能運作(也就是漸進式增強),useActionState 還有第三個選用參數 permalink,指定表單在沒有 JS 時應該導向的 URL。想深入了解驗證的部分,可以參考Next.js Server Actions + Zod 驗證教學。
useOptimistic:讓 UI 不再等待
就算 useActionState 幫你管好了 pending 狀態,使用者還是得盯著 loading spinner 等伺服器回應。這就是 useOptimistic 登場的時候——它讓你在非同步操作還沒完成時,就先更新 UI,給使用者「操作已經成功」的即時回饋。
關於 useOptimistic 的基礎概念,之前在React 19 useOptimistic 與 useFormStatus 教學有詳細介紹過。這裡我們聚焦在它跟 useActionState 的組合技。
import { useOptimistic } from 'react';
function TodoList({ todos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTitle) => [
...currentTodos,
{ id: 'temp-' + Date.now(), title: newTitle, pending: true },
]
);
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>
{todo.title}
{todo.pending && ' (儲存中...)'}
</li>
))}
</ul>
);
}
useOptimistic 接收目前的真實資料和一個更新函式。呼叫 addOptimisticTodo 時,UI 會立刻顯示暫時的新項目。等到 action 完成、元件重新渲染時,樂觀更新會自動被真實資料取代。
組合技:useActionState + useOptimistic
現在來看最完整的實戰範例——把兩個 Hook 結合起來,打造一個使用者體驗極佳的待辦清單。
'use client';
import { useActionState, useOptimistic } from 'react';
import { createTodo } from '../actions';
export function TodoApp({ initialTodos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(current, newTitle) => [
...current,
{ id: crypto.randomUUID(), title: newTitle, pending: true },
]
);
async function handleCreateTodo(prevState, formData) {
const title = formData.get('title');
addOptimisticTodo(title);
return await createTodo(prevState, formData);
}
const [state, formAction, isPending] = useActionState(
handleCreateTodo,
{ error: null }
);
return (
<div>
<form action={formAction}>
<input type="text" name="title" placeholder="新增待辦..." />
<button type="submit">新增</button>
</form>
{state.error && <p className="error">{state.error}</p>}
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
{todo.title}
</li>
))}
</ul>
</div>
);
}
這段程式碼的流程是:使用者按下「新增」→ addOptimisticTodo 立刻在列表中新增一筆半透明的項目 → Server Action 在背景執行 → 成功後元件重新渲染,樂觀更新被真實資料取代。整個過程使用者完全不用等待,體驗非常流暢。
什麼時候該用?什麼時候不該用?
useActionState + useOptimistic 非常適合這些場景:
- 表單提交:登入、註冊、留言、評價等需要送資料到伺服器的操作
- 即時互動:按讚、收藏、投票等需要立即回饋的功能
- CRUD 操作:新增、編輯、刪除資料,搭配樂觀更新讓 UI 不卡頓
- 漸進式增強:需要在 JavaScript 未載入時也能運作的表單
但如果你的需求涉及快取管理、背景重新抓取、分頁、無限捲動等進階功能,React Query 或 Redux Toolkit 仍然是更好的選擇。useActionState 處理的是「單次操作的狀態」,不是「全域資料的快取」。
跟舊寫法的比較
用一張表來看差異:
| 功能 | 舊寫法(useState + useTransition) | 新寫法(useActionState) |
|---|---|---|
| Loading 狀態 | 手動管理 useState | 自動提供 isPending |
| 錯誤處理 | try-catch + setState | 回傳值即狀態 |
| Server Actions | 需要額外包裝 | 原生支援 |
| 漸進式增強 | 不支援 | permalink 參數 |
| 程式碼行數 | 約 25-35 行 | 約 10-15 行 |
程式碼量直接砍半,可讀性也大幅提升。
實作小提醒
- 永遠處理錯誤狀態:樂觀更新最怕的就是 action 失敗但 UI 已經更新了。確保失敗時有回復機制,
useOptimistic會在 action reject 時自動回退。 - FormData 優先:善用原生
FormData而不是 controlled components,可以減少不必要的重新渲染。 - 跟 RSC 搭配:
useActionState加上React Server Components 能讓初始渲染更快,因為伺服器回傳的狀態可以直接呈現。 - 型別安全:用 TypeScript 定義 action 的回傳型別,避免 state 結構不一致的問題。
總結
useActionState 和 useOptimistic 是 React 19 最實用的兩個 Hook。前者把表單操作的狀態管理簡化到極致,後者讓使用者不用再盯著 loading spinner 發呆。兩者搭配使用,再加上 Server Actions 的整合,你可以用更少的程式碼、更清楚的邏輯,打造出使用者體驗一流的互動介面。
下次當你準備寫一個表單元件時,先問自己:我真的需要 useState + useTransition + try-catch 那一大堆嗎?還是 useActionState 就搞定了?大多數時候,答案是後者。
繼續閱讀
React Signals 是什麼?2026 年最值得關注的前端狀態管理新方案完整解析
相關文章
你可能也喜歡
探索其他領域的精選好文