Safe Action Hooks

next-safe-action/skills/skills/safe-action-hooks

作者 next-safe-actiona2605bd2e84321245cba8f5718c144a6e4a5fa47無授權條款收錄於 2026年10月9日更新於 2026年10月9日

Use when executing next-safe-action actions from React client components or building optimistic UI -- useAction, useOptimisticAction, useStateAction, useOptimisticStateAction, status/callbacks (onSuccess/onError/onSettled), execute vs executeAsync, formAction, reset, and queued or serialized mutations such as drag-to-reorder, Kanban boards, overlapping saves, accumulating changes, and shared pending-change lists

AI 產生的概覽

說明如何使用 next-safe-action 的 React hooks 執行伺服器動作、樂觀更新與排隊狀態變更。

功能
這個技能說明 next-safe-action 的 React hooks:useAction、useOptimisticAction、useStateAction 與 useOptimisticStateAction。它解釋各 hook 與對應 action 方法的搭配、共用的回傳值(例如 execute、executeAsync、狀態旗標與 reset),以及 initResult 選項。內容也涵蓋樂觀更新、用於累積寫入的排隊派送、formAction 用法與常見反模式。
適用情境
當你需要從 React 用戶端元件呼叫 next-safe-action 動作、在四個 hook 之間做選擇,或建立樂觀 UI 時使用。它也適合重疊或排隊的變更,例如拖曳排序、看板以及累積式修改。
執行需求
需要一個使用 next-safe-action 函式庫的 React 專案;內容假設採用 Next.js 伺服器動作,表單輸入情境還需要能解析 FormData 的 schema,例如 zod-form-data。這個技能不含指令碼,僅為說明文件。

next-safe-action React Hooks

Import

ts
// All hooksimport {  useAction,  useOptimisticAction,  useStateAction,  useOptimisticStateAction,} from "next-safe-action/hooks";
// Backward-compatible re-export (same useStateAction hook)import { useStateAction } from "next-safe-action/stateful-hooks";

Which Hook

HookAction methodUse for
useAction.action()Programmatic triggers, interactive UI, most cases
useOptimisticAction.action()Instant UI for changes that replace state (last-write-wins)
useStateAction.stateAction()<form action={formAction}>, prevResult on the server, queued dispatches
useOptimisticStateAction.stateAction()Instant UI for changes that accumulate: overlapping writes are queued, each folding over the confirmed state

Pick by action kind first: .action() pairs with useAction / useOptimisticAction, .stateAction() pairs with useStateAction / useOptimisticStateAction.

useAction — Quick Start

tsx
"use client";
import { useAction } from "next-safe-action/hooks";import { createUser } from "@/app/actions";
export function CreateUserForm() {  const { execute, result, status, isExecuting, isPending } = useAction(createUser, {    onSuccess: ({ data }) => {      console.log("User created:", data);    },    onError: ({ error }) => {      console.error("Failed:", error.serverError);    },  });
  return (    <form onSubmit={(e) => {      e.preventDefault();      const formData = new FormData(e.currentTarget);      execute({ name: formData.get("name") as string });    }}>      <input name="name" required />      <button type="submit" disabled={isPending}>        {isPending ? "Creating..." : "Create User"}      </button>      {result.serverError && <p className="error">{result.serverError}</p>}      {result.data && <p className="success">Created: {result.data.id}</p>}    </form>  );}

useOptimisticAction — Quick Start

tsx
"use client";
import { useOptimisticAction } from "next-safe-action/hooks";import { toggleTodo } from "@/app/actions";
export function TodoItem({ todo }: { todo: Todo }) {  const { execute, optimisticState } = useOptimisticAction(toggleTodo, {    currentState: todo,    updateFn: (state, input) => ({      ...state,      completed: !state.completed,    }),  });
  return (    <label>      <input        type="checkbox"        checked={optimisticState.completed}        onChange={() => execute({ todoId: todo.id })}      />      {todo.title}    </label>  );}

useOptimisticStateAction — Quick Start

For overlapping writes that must accumulate (reorder an item, then reorder it again before the first save lands). Dispatches are queued: each waits for the previous to settle, and the server receives the last confirmed state as prevResult.

tsx
"use client";
import { useOptimisticStateAction } from "next-safe-action/hooks";import { moveItem } from "@/app/actions";import { reorder } from "@/lib/reorder"; // the same reducer the server runs
export function ReorderList({ items }: { items: Item[] }) {  const { execute, optimisticState, isPending } = useOptimisticStateAction(moveItem, {    currentState: items, // must be a stable reference — compared by identity    updateFn: reorder,  });
  return (    <ul data-saving={isPending}>      {optimisticState.map((item) => (        <li key={item.id}>          {item.label}          <button onClick={() => execute({ id: item.id, direction: "up" })}>Up</button>        </li>      ))}    </ul>  );}

The action must be a .stateAction(), and must revalidate the state the page renders:

ts
"use server";
export const moveItem = actionClient  .inputSchema(moveSchema)  .stateAction(async ({ parsedInput }, { prevResult }) => {    const next = reorder(prevResult.data!, parsedInput); // always the last confirmed state    await db.items.save(next);    revalidatePath("/items");    return next;  });

See useOptimisticStateAction in depth for the decision rules, the pending-changes-list shape, and the gotchas.

useStateAction — Quick Start

tsx
"use client";
import { useStateAction } from "next-safe-action/hooks";import { submitFeedback } from "@/app/actions";
export function FeedbackForm() {  const { formAction, result, isPending, hasSucceeded } = useStateAction(submitFeedback, {    onSuccess: ({ data }) => {      console.log("Submitted:", data);    },    onError: ({ error }) => {      console.error("Failed:", error.serverError);    },  });
  return (    <form action={formAction}>      <input name="rating" type="number" min="1" max="5" required />      <textarea name="comment" required />      <button type="submit" disabled={isPending}>        {isPending ? "Submitting..." : "Submit"}      </button>      {result.validationErrors?.comment && (        <p className="error">{result.validationErrors.comment._errors[0]}</p>      )}      {hasSucceeded && <p className="success">Thank you!</p>}    </form>  );}

The server-side action must use .stateAction() (not .action()). <form action={formAction}> submits raw FormData, so the input schema must parse FormData (e.g. with zod-form-data):

ts
"use server";
import { z } from "zod";import { zfd } from "zod-form-data";import { actionClient } from "@/lib/safe-action";
export const submitFeedback = actionClient  .inputSchema(    zfd.formData({      rating: zfd.numeric(z.number().min(1).max(5)),      comment: zfd.text(z.string()),    })  )  .stateAction(async ({ parsedInput }, { prevResult }) => {    // prevResult contains the previous SafeActionResult    await db.feedback.create({ data: parsedInput });    return { rating: parsedInput.rating };  });

Return Value

All hooks (useAction, useOptimisticAction, useStateAction, useOptimisticStateAction) return:

PropertyTypeDescription
execute(input)(input) => voidFire-and-forget execution
executeAsync(input)(input) => Promise<Result>Returns a promise with the result
inputInput | undefinedLast input dispatched (via execute, executeAsync, or formAction)
resultSafeActionResultLast action result — discriminated union of 4 branches (idle / success / serverError / validationErrors); narrowed when you check status or any has* shorthand
reset()() => voidResets client state to initial (restores initResult if provided) and ignores the result of any in-flight execution. It does not cancel the server call already in flight. On the queued hooks (useStateAction, useOptimisticStateAction) it also skips every dispatch still waiting its turn: their action never runs and no write happens
statusHookActionStatusCurrent status string
isIdlebooleanNo execution has started yet
isExecutingbooleanAction promise is pending
isTransitioningbooleanReact transition is pending
isPendingbooleanisExecuting || isTransitioning, except after reset(): a reset reports idle immediately, even while the uncancellable transition it interrupted is still settling
hasSucceededbooleanLast execution completed without errors (a void action succeeds with result.data still undefined)
hasErroredbooleanLast execution had serverError, validationErrors, or threw (a raw throw leaves result empty)
hasNavigatedbooleanLast execution triggered a navigation

useOptimisticAction additionally returns: | optimisticState | State | The optimistically-updated state |

useStateAction additionally returns: | formAction | (input) => void | Dispatcher for <form action={formAction}> pattern |

useOptimisticStateAction returns everything useStateAction returns, plus: | optimisticState | State | Confirmed state folded with every in-flight change (always defined) |

The hook return is itself a discriminated union keyed on status and every has* / is* shorthand (each typed as literal true / false per branch). Narrowing any discriminant narrows result — e.g. inside if (hasSucceeded), result.data is Data (not Data | undefined). See Type narrowing via hook status.

initResult Option

All hooks accept initResult to seed the hook with a preloaded result (e.g. data fetched on the server): in the opts object for useAction/useStateAction, in the utils object (alongside currentState/updateFn) for useOptimisticAction/useOptimisticStateAction. The value is captured once at mount (like React's useActionState initial state): later changes to the option are ignored, and reset() restores the mount value. The seeded shape precisely types the idle branch's result. See initResult in depth.

Supporting Docs

  • execute vs executeAsync, result handling
  • useStateAction in depth (decision table, formAction)
  • Optimistic updates with useOptimisticAction
  • useOptimisticStateAction: queued optimistic updates for overlapping writes
  • Status lifecycle and all callbacks
  • throwOnNavigation flag

Anti-Patterns

ts
// BAD: Using executeAsync without try/catch when navigation errors are possibleconst handleClick = async () => {  const result = await executeAsync({ id }); // Throws on redirect!  showToast(result.data);};
// GOOD: Wrap executeAsync in try/catchconst handleClick = async () => {  try {    const result = await executeAsync({ id });    showToast(result.data);  } catch (e) {    // Handle non-navigation errors here if needed, then re-throw    // Navigation errors must propagate to Next.js    throw e;  }};
tsx
// BAD: Last-write-wins for changes that accumulate — the second move builds on stale state,// and the response of the first move is discardedconst { execute } = useOptimisticAction(moveItem, { currentState: items, updateFn: reorder });
// GOOD: Queued dispatches, each folding over the previous resultconst { execute } = useOptimisticStateAction(moveItem, { currentState: items, updateFn: reorder });
ts
// BAD: Using .action() with useStateAction — type errorconst myAction = actionClient.inputSchema(schema).action(async ({ parsedInput }) => { ... });useStateAction(myAction); // TypeScript error!
// GOOD: Use .stateAction() for useStateActionconst myAction = actionClient.inputSchema(schema).stateAction(async ({ parsedInput }, { prevResult }) => { ... });useStateAction(myAction); // Works!

來源與署名

來源:next-safe-action/skills位於skills/safe-action-hooks提交a2605bd

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架