跳到主要内容

Server Actions

Server Actions 是 Next.js 提供的服务端函数,可以在服务端执行数据变更操作。

基本用法

定义 Server Action

在函数顶部添加 "use server" 指令:

// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";

export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;

await db.post.create({
data: { title, content },
});

revalidatePath("/posts");
}

在表单中使用

// app/posts/page.tsx
import { createPost } from "./actions";

export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" type="text" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<button type="submit">发布</button>
</form>
);
}

表单处理

获取表单数据

"use server";

export async function submitForm(formData: FormData) {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const age = parseInt(formData.get("age") as string);
const subscribed = formData.get("subscribe") === "on";

console.log({ name, email, age, subscribed });
}

多个表单字段

"use server";

export async function createOrder(formData: FormData) {
const items = formData.getAll("items") as string[];
const quantities = formData.getAll("quantities") as string[];

const orderItems = items.map((item, index) => ({
item,
quantity: parseInt(quantities[index]),
}));

await createOrderInDatabase(orderItems);
}

文件上传

"use server";

export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File;

if (!file) {
return { error: "请选择文件" };
}

const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);

const filename = `${Date.now()}-${file.name}`;
await writeFile(`./uploads/${filename}`, buffer);

return { success: true, filename };
}

状态处理

使用 useActionState

React 19 引入了 useActionState Hook,它是 useFormState 的升级版本。useFormState 在 React 19 中已被废弃,推荐使用 useActionState

// app/actions.ts
"use server";

export async function createPost(
prevState: { error?: string; success?: boolean },
formData: FormData
) {
const title = formData.get("title") as string;

if (!title || title.length < 3) {
return { error: "标题至少需要 3 个字符" };
}

try {
await db.post.create({ data: { title } });
return { success: true };
} catch (error) {
return { error: "创建失败" };
}
}
// app/posts/page.tsx
"use client";

import { useActionState } from "react";
import { createPost } from "./actions";

export default function NewPostPage() {
// useActionState 返回 [state, dispatchAction, isPending]
// state: 当前状态
// dispatchAction: 触发 action 的函数
// isPending: 是否正在处理中
const [state, formAction, isPending] = useActionState(createPost, {
error: undefined,
success: false,
});

return (
<form action={formAction}>
<input name="title" type="text" required />
{state.error && <p className="text-red-500">{state.error}</p>}
{state.success && <p className="text-green-500">创建成功</p>}
<button type="submit" disabled={isPending}>
{isPending ? "提交中..." : "提交"}
</button>
</form>
);
}
useActionState vs useFormState

useActionState 是 React 19 新增的 Hook,相比 useFormState 有以下改进:

  • 直接从 react 包导入,而不是 react-dom
  • 返回 isPending 状态,无需额外使用 useFormStatus
  • 支持第三个可选参数 permalink,用于渐进增强

使用 useFormStatus

useFormStatus 仍然可用,用于获取表单提交状态。它提供了更详细的表单信息:

"use client";

import { useFormStatus } from "react-dom";

function SubmitButton() {
const { pending, data, method, action } = useFormStatus();

return (
<button type="submit" disabled={pending}>
{pending ? "提交中..." : "提交"}
</button>
);
}

export default function Form() {
return (
<form action={createPost}>
<input name="title" type="text" required />
<SubmitButton />
</form>
);
}

useFormStatus 返回的对象包含:

  • pending: 是否正在提交
  • data: 提交的 FormData(React 19 新增)
  • method: 表单方法(React 19 新增)
  • action: 表单 action 属性值(React 19 新增)

数据验证

使用 Zod 验证

// app/actions.ts
"use server";

import { z } from "zod";

const postSchema = z.object({
title: z.string().min(3, "标题至少 3 个字符").max(100),
content: z.string().min(10, "内容至少 10 个字符"),
category: z.enum(["tech", "life", "other"]),
});

export async function createPost(formData: FormData) {
const rawData = {
title: formData.get("title"),
content: formData.get("content"),
category: formData.get("category"),
};

const result = postSchema.safeParse(rawData);

if (!result.success) {
return {
error: result.error.flatten().fieldErrors,
};
}

await db.post.create({ data: result.data });
revalidatePath("/posts");
}

重新验证

重新验证路径

"use server";

import { revalidatePath } from "next/cache";

export async function updatePost(formData: FormData) {
await updatePostInDatabase(formData);

revalidatePath("/posts");
revalidatePath(`/posts/${formData.get("id")}`);
}

重新验证标签

"use server";

import { revalidateTag } from "next/cache";

export async function updateProducts() {
await updateProductsInDatabase();

revalidateTag("products");
}

重定向

"use server";

import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
const post = await db.post.create({
data: {
title: formData.get("title") as string,
},
});

redirect(`/posts/${post.id}`);
}
"use server";

import { cookies } from "next/headers";

export async function login(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;

const user = await verifyUser(email, password);

if (!user) {
return { error: "登录失败" };
}

const cookieStore = await cookies();
cookieStore.set("token", user.token, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 60 * 60 * 24 * 7,
});

return { success: true };
}

export async function logout() {
const cookieStore = await cookies();
cookieStore.delete("token");
}

实战示例

完整的 CRUD 操作

// app/posts/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { db } from "@/lib/db";

const postSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
});

export async function createPost(prevState: { error?: Record<string, string[]>; success?: boolean }, formData: FormData) {
const result = postSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});

if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}

const post = await db.post.create({ data: result.data });
revalidatePath("/posts");
redirect(`/posts/${post.id}`);
}

export async function updatePost(prevState: { error?: Record<string, string[]>; success?: boolean }, formData: FormData) {
const id = formData.get("id") as string;

const result = postSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});

if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}

await db.post.update({
where: { id },
data: result.data,
});

revalidatePath("/posts");
revalidatePath(`/posts/${id}`);
return { success: true };
}

export async function deletePost(formData: FormData) {
const id = formData.get("id") as string;
await db.post.delete({ where: { id } });
revalidatePath("/posts");
}
// app/posts/new/page.tsx
"use client";

import { useActionState } from "react";
import { createPost } from "../actions";

export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, {
error: undefined,
success: false,
});

return (
<form action={formAction} className="space-y-4">
<div>
<label className="block mb-1">标题</label>
<input
name="title"
type="text"
required
className="w-full px-3 py-2 border rounded"
/>
{state.error?.title && (
<p className="text-red-500 text-sm mt-1">{state.error.title[0]}</p>
)}
</div>
<div>
<label className="block mb-1">内容</label>
<textarea
name="content"
required
className="w-full px-3 py-2 border rounded"
rows={5}
/>
{state.error?.content && (
<p className="text-red-500 text-sm mt-1">{state.error.content[0]}</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
>
{isPending ? "发布中..." : "发布"}
</button>
</form>
);
}
// app/posts/[id]/page.tsx
import { deletePost } from "../actions";

export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await getPost(id);

if (!post) {
return <div>文章不存在</div>;
}

return (
<article>
<h1 className="text-2xl font-bold">{post.title}</h1>
<p className="mt-4">{post.content}</p>
<form action={deletePost} className="mt-4">
<input type="hidden" name="id" value={id} />
<button type="submit" className="px-4 py-2 bg-red-500 text-white rounded">
删除
</button>
</form>
</article>
);
}

处理多个操作类型

useActionState 支持通过 action payload 处理多种操作:

"use client";

import { useActionState } from "react";

type State = {
count: number;
error?: string;
};

async function counterAction(prevState: State, payload: { type: "increment" | "decrement" }) {
switch (payload.type) {
case "increment":
return { count: prevState.count + 1 };
case "decrement":
return { count: Math.max(0, prevState.count - 1) };
default:
return prevState;
}
}

export default function Counter() {
const [state, dispatchAction, isPending] = useActionState(counterAction, { count: 0 });

return (
<div className="flex items-center gap-4">
<button
onClick={() => dispatchAction({ type: "decrement" })}
disabled={isPending || state.count === 0}
>
-
</button>
<span>{state.count}</span>
<button onClick={() => dispatchAction({ type: "increment" })} disabled={isPending}>
+
</button>
</div>
);
}

小结

本章我们学习了:

  1. Server Actions 的基本用法
  2. 表单处理和文件上传
  3. 状态处理(useActionState、useFormStatus)
  4. 数据验证
  5. 重新验证和重定向
  6. Cookie 操作
  7. 完整的 CRUD 示例

练习

  1. 创建一个带有验证的表单提交功能
  2. 实现一个完整的 CRUD 操作
  3. 使用 useActionState 处理表单错误和加载状态
  4. 实现文件上传功能