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}`);
}
Cookie 操作
"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>
);
}
小结
本章我们学习了:
- Server Actions 的基本用法
- 表单处理和文件上传
- 状态处理(useActionState、useFormStatus)
- 数据验证
- 重新验证和重定向
- Cookie 操作
- 完整的 CRUD 示例
练习
- 创建一个带有验证的表单提交功能
- 实现一个完整的 CRUD 操作
- 使用 useActionState 处理表单错误和加载状态
- 实现文件上传功能