mcpField
S
safe-action-hooks

safe-action-hooks

github.com/next-safe-action开发11.2k 次安装8 周前更新
代码仓库

关于 safe-action-hooks

为 React 客户端组件调用 next-safe-action 操作提供 Hooks,并管理结果与状态。不同 Hook 分别适配普通 action 和 stateAction,也支持覆盖式或排队累积式乐观更新。

主要功能

  1. 01提供四种 action 与 stateAction Hooks
  2. 02跟踪执行状态和结果
  3. 03支持乐观状态更新
  4. 04排队处理累积型更改
  5. 05初始化结果与重置

适用场景

  1. 01从交互界面触发操作
  2. 02通过 stateAction 提交 FormData
  3. 03乐观替换显示状态
  4. 04依序应用多个重叠更改

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/next-safe-action/skills --skill safe-action-hooks

安装后在 Claude Code 中输入/safe-action-hooks确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

optimistic-updates.md
status-callbacks.md
throw-on-navigation.md
use-action.md
use-optimistic-state-action.md
use-state-action.md

详细介绍

next-safe-action React Hooks

导入

// All hooks
import {
  useAction,
  useOptimisticAction,
  useStateAction,
  useOptimisticStateAction,
} from "next-safe-action/hooks";

// Backward-compatible re-export (same useStateAction hook)
import { useStateAction } from "next-safe-action/stateful-hooks";

选择哪个 Hook

HookAction 方法适用场景
useAction.action()以编程方式触发、交互式 UI、大多数场景
useOptimisticAction.action()立即更新会替换状态的 UI(最后写入优先)
useStateAction.stateAction()<form action={formAction}>、服务端的 prevResult、排队的分发
useOptimisticStateAction.stateAction()立即更新会累积的 UI:重叠写入会排队,每次都基于已确认的状态进行折叠

首先根据 Action 类型选择:.action() 对应 useAction / useOptimisticAction,.stateAction() 对应 useStateAction / useOptimisticStateAction。

useAction — 快速入门

"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 — 快速入门

"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 — 快速入门

适用于必须累积的重叠写入(例如,重新排序一个项目后,在第一次保存完成前再次重新排序)。分发会排队:每次都要等上一次完成,然后服务端会将最后确认的状态作为 prevResult 接收。

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

Action 必须是 .stateAction(),并且必须重新验证页面所呈现的状态:

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

请参阅 深入了解 useOptimisticStateAction,了解决策规则、待处理更改列表的结构以及注意事项。

useStateAction — 快速入门

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

服务端 Action 必须使用 .stateAction()(而不是 .action())。<form action={formAction}> 会提交原始 FormData,因此输入架构必须能解析 FormData(例如,使用 zod-form-data):

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

返回值

所有钩子(useAction、useOptimisticAction、useStateAction、useOptimisticStateAction)均返回:

属性类型描述
execute(input)(input) => void发起即忘的执行
executeAsync(input)(input) => Promise<Result>返回包含结果的 Promise
inputInput | undefined最近一次派发的输入(通过 execute、executeAsync 或 formAction)
resultSafeActionResult最近一次操作结果——由 4 个分支组成的可辨识联合(idle / success / serverError / validationErrors);检查 status 或任意 has* 简写时会收窄类型
reset()() => void将客户端状态重置为初始状态(如果提供了 initResult,则恢复该值),并忽略任何正在执行的操作的结果。它不会取消已经发出的服务端调用。对于排队钩子(useStateAction、useOptimisticStateAction),它还会跳过所有仍在等待执行的派发:对应操作不会运行,也不会发生写入
statusHookActionStatus当前状态字符串
isIdleboolean尚未开始执行
isExecutingboolean操作 Promise 正在等待完成
isTransitioningbooleanReact transition 正在等待完成
isPendingbooleanisExecuting || isTransitioning,但调用 reset() 后除外:即使它打断的、无法取消的 transition 仍在完成过程中,重置也会立即报告 idle
hasSucceededboolean最近一次执行已完成且没有错误(无返回值的操作成功时,result.data 仍为 undefined)
hasErroredboolean最近一次执行出现了 serverError、validationErrors,或抛出了异常(直接抛出的异常会使 result 为空)
hasNavigatedboolean最近一次执行触发了导航

useOptimisticAction 还会返回: | optimisticState | State | 乐观更新后的状态 |

useStateAction 还会返回: | formAction | (input) => void | <form action={formAction}> 模式的派发器 |

useOptimisticStateAction 返回 useStateAction 的所有内容,另外还会返回: | optimisticState | State | 与所有正在执行的更改合并后的已确认状态(始终有定义) |

钩子本身的返回值也是一个可辨识联合,由 status 以及每个 has* / is* 简写作为判别键(每个分支中的类型均为字面量 true / false)。收窄任意判别键都会收窄 result——例如,在 if (hasSucceeded) 中,result.data 的类型为 Data(而非 Data | undefined)。请参阅通过钩子状态收窄类型。

initResult 选项

所有钩子都接受 initResult,用于以预加载结果(例如从服务端获取的数据)初始化钩子:对于 useAction / useStateAction,在 opts 对象中提供;对于 useOptimisticAction / useOptimisticStateAction,在 utils 对象中提供(与 currentState / updateFn 并列)。该值仅在挂载时捕获一次(类似于 React 的 useActionState 初始状态):之后对该选项的更改会被忽略,reset() 会恢复挂载时的值。预置值的形状会精确决定 idle 分支中 result 的类型。请参阅深入了解 initResult。

相关文档

反模式

// BAD: Using executeAsync without try/catch when navigation errors are possible
const handleClick = async () => {
  const result = await executeAsync({ id }); // Throws on redirect!
  showToast(result.data);
};

// GOOD: Wrap executeAsync in try/catch
const 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;
  }
};
// BAD: Last-write-wins for changes that accumulate — the second move builds on stale state,
// and the response of the first move is discarded
const { execute } = useOptimisticAction(moveItem, { currentState: items, updateFn: reorder });

// GOOD: Queued dispatches, each folding over the previous result
const { execute } = useOptimisticStateAction(moveItem, { currentState: items, updateFn: reorder });
// BAD: Using .action() with useStateAction — type error
const myAction = actionClient.inputSchema(schema).action(async ({ parsedInput }) => { ... });
useStateAction(myAction); // TypeScript error!

// GOOD: Use .stateAction() for useStateAction
const myAction = actionClient.inputSchema(schema).stateAction(async ({ parsedInput }, { prevResult }) => { ... });
useStateAction(myAction); // Works!

评论

支持 Markdown