表单写入与服务端动作
表单写入与服务端动作 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
写笔记不能只靠接口——表单才是主入口。本课用 Next.js 当前推荐的方式(Server Action)把「新建笔记」从浏览器一路写进 SQLite:服务端校验、错误回显、防重复提交,最后用连点按钮验证重复请求不产生脏数据。
问题场景
保存笔记的老写法:前端校验标题非空,再 fetch POST /api/notes,再处理响应、维护 loading 状态……一套流程要养两份校验规则和一个请求状态机。Server Action 把「表单提交」变成一个服务端函数调用:框架自动发请求,你只写函数本身。
核心解释
Server Action 是标了 "use server" 的 async 函数,只在服务端执行;表单的 action 属性直接指向它:
// app/notes/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { getDb, newId, nowIso } from "@/lib/db/client";
const NoteSchema = z.object({
title: z.string().min(1, "标题不能为空").max(200),
body: z.string().min(1, "内容不能为空").max(50000),
});
export type SaveState = {
ok: boolean;
message?: string;
fields?: Record<string, string[]>;
};
export async function saveNote(_prev: SaveState, formData: FormData): Promise<SaveState> {
const parsed = NoteSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!parsed.success) {
return { ok: false, fields: parsed.error.flatten().fieldErrors };
}
const { title, body } = parsed.data;
getDb()
.prepare(
"INSERT INTO note (id, title, body, revision, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)"
)
.run(newId("note_"), title, body, nowIso(), nowIso());
revalidatePath("/notes");
return { ok: true, message: "已保存" };
}
客户端用 React 19 的 useActionState 把状态接回来:
// app/notes/new/note-form.tsx
"use client";
import { useActionState } from "react";
import { saveNote, type SaveState } from "../actions";
const initial: SaveState = { ok: false };
export function NoteForm() {
const [state, formAction, pending] = useActionState(saveNote, initial);
return (
<form action={formAction}>
<input name="title" aria-invalid={!!state.fields?.title} />
{state.fields?.title?.map((m) => (
<p key={m} role="alert">{m}</p>
))}
<textarea name="body" />
<button disabled={pending}>{pending ? "保存中……" : "保存"}</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
三个要点:
- 校验只写一份,放在服务端。Schema 在 Action 里执行,返回的错误状态负责回显——单一事实来源,不存在「前端一套规则、后端一套规则」。
- 幂等与重复提交。
pending期间禁用按钮挡住手抖双击;更进一步可在 Action 里查重或靠数据库唯一约束兜底,让同一内容提交两次也只落一条。 - 写完立刻失效缓存。Action 完成后框架自动带回当前页的新渲染,列表页等其他引用点要 revalidatePath 显式失效。
示例拆解
验证清单(本机工程执行):
npm run dev,打开新建笔记页,空标题提交——看到「标题不能为空」,数据库无新记录。- 填好后点保存——按钮进入「保存中……」,完成后列表页出现新笔记。
- 疯狂连点保存按钮——
SELECT COUNT(*) FROM note只多了一条。 - 重启 dev server 再刷新——笔记仍在:写进的是 SQLite 文件,不是内存。
关键点:表单没有任何 onChange 受控逻辑也能工作——Server Action 拿到的是原生 FormData 提交,这正是它能脱离 JS 也渐进工作的原因。
动手任务
在本机工程里完成:
- 实现 saveNote + NoteForm,跑通「空标题报错 → 正常保存 → 列表可见」。
- 给 Action 加内容查重(或数据库唯一约束),验证同一内容连提两次只落一条。
- 临时去掉按钮的 pending 禁用,快速双击复现重复插入,再恢复禁用并复测。
常见错误
- 在 Action 里读写 window / document / localStorage:Action 在服务端跑,没有这些对象;浏览器侧逻辑放客户端组件。
- 只在前端校验:curl 直接 POST 就能绕过;校验必须以服务端为唯一权威。
- 忘了 "use server",或把它加进客户端组件文件:前者调用报错;后者语义错误——该指令文件里只能导出 async 函数。
- 保存成功但列表页还是旧数据:漏了 revalidatePath,写操作没有绑定缓存失效。
检查与复盘
自查清单:
- 一份 Schema 同时承担服务端校验与错误回显
- 双击与重试不会产生重复数据,说得清是哪一层挡住的
- 保存后相关页面立即看到新数据
参考资料
- Next.js 文档:Updating Data —— Server Action 写数据的官方指南
- Next.js 文档:Server Actions and Mutations —— 指令细节与约束
- React 文档:useActionState —— 表单状态回传 Hook
读完这一课了?
标记完成后,工作台会安排接下来的学习。