跳到主要内容

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. 实现文件上传功能