REACT · Vol.III · LESSON 21 · 状态管理
表单状态管理(对比 Spring 表单绑定)
表单,是一类最难伺候的客户端状态
第 20 篇给全局状态做完选型时留了个尾巴:表单输入这类高频临时值,连状态提升都要慎重,更别提塞进 Zustand。这一篇专门把尾巴收掉。
先给表单状态画个像,它同时踩中三个特征:
- 高频:每敲一个字符就变一次,变更频率比购物车点击高一个量级;
- 局部:email、密码这些值出了表单没人关心,放进全局 store 纯属污染;
- 带校验:必填、格式、长度、两次密码一致……规则成打,还会互相联动。
按第 17 篇的分类它是纯客户端状态,但裸 useState 很快撞墙:
import { useState } from "react";
import type { FormEvent } from "react";
function RegisterForm() {
// 痛点一:每个字段一份 state,十个字段十份样板
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [nickname, setNickname] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
// 痛点三:校验散落在组件里,改规则得回这里翻
const validate = () => {
const next: Record<string, string> = {};
if (!email.includes("@")) next.email = "邮箱格式不正确";
if (password.length < 8) next.password = "密码至少 8 位";
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!validate()) return;
// 痛点一的余波:提交前还得手动把散件拼回 DTO
submitToApi({ email, password, nickname });
};
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
// 每个字段都要亲手接线:value + onChange 成对出现
<button>注册</button>
</form>
);
}
三宗罪数完:
- 每字段一个 state:声明一份、接线一份、提交时再拼一份,全是样板;
- 每键一次重渲染:setEmail 触发整个 RegisterForm 重渲染,字段一多打字都卡(怎么量化留给第 37 篇);
- 校验散落:onChange 里查一次、提交前查一次;「两次密码一致」这种跨字段规则更是没地方放。
Spring MVC 里你从不 request.getParameter("email") 一个个取参、手动转类型、手动判空——@ModelAttribute 负责绑定对象、@Valid 负责触发校验,全是框架的事。裸 useState 表单恰恰是前端版的手写 getParameter:绑定靠手、校验靠记、拼 DTO 靠加班。
React Hook Form:值住 DOM 里,登记一下就行
破局思路是把方向调头。裸 useState 是受控:值存在 React state 里,每敲一键都通知 React 重算。另一条路是非受控:值就住在 input 的 DOM 里,提交时统一收集——浏览器原生 form 提交本来就是这么干的。React Hook Form(npm install react-hook-form)把这条路工程化:
import { useForm } from "react-hook-form";
interface RegisterValues {
email: string;
password: string;
nickname: string;
}
function RegisterForm() {
const { register, handleSubmit, formState: { errors } } =
useForm<RegisterValues>();
// 只有校验通过,onSubmit 才会被调用;values 已是拼好的对象
const onSubmit = (values: RegisterValues) => {
submitToApi(values); // { email, password, nickname }
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("email", { required: "邮箱必填" })} placeholder="邮箱" />
{errors.email && <p className="error">{errors.email.message}</p>}
<input {...register("password", { minLength: { value: 8, message: "至少 8 位" } })} type="password" />
<input {...register("nickname")} placeholder="昵称" />
<button type="submit">注册</button>
</form>
);
}
三个关键角色:
- register("email"):返回
{ name, ref, onChange, onBlur }展开到 input 上,本质是把 input 的 DOM ref「登记」进 RHF 的内部仓库——机制上就是第 12 篇 useRef 那套 ref + 事件转发。值从此不经过 React state。 - 非受控 = 值住 DOM:打字时只有 DOM 自己在变,React 全程不参与,每键零重渲染。唯一被订阅的是 errors——它真的变了,组件才重渲染。
- handleSubmit(onSubmit):提交拦截器——先跑校验,通过才把拼好的 values 交给 onSubmit;不通过根本进不了业务函数。连
e.preventDefault()都替你调了。
受控与非受控的完整对决留给第 24 篇,这里先记结论:信息收集型表单,非受控收集是性能与简洁的默认解。
register 的参数就是绑定字段名(相当于请求参数名),handleSubmit 是校验闸门,onSubmit(values) 才是真正进业务逻辑的 Controller 方法体——DTO 绑定、校验拦截这两步,和 Spring 干的是同一件事,只是发生在浏览器里。
Zod schema:把 Bean Validation 搬进前端
上一站留了个疙瘩:校验规则写在 register 的参数里,又跟着组件散落了,跨字段规则依然没地方放。解法和后端一模一样——把校验从组件里抽出来,声明式集中成一份 schema。装依赖:npm install zod @hookform/resolvers:
import { z } from "zod";
export const registerSchema = z.object({
email: z.string().min(1, "邮箱必填").email("邮箱格式不正确"),
password: z.string().min(8, "密码至少 8 位").regex(/[A-Z]/, "必须包含大写字母"),
nickname: z.string().min(2, "昵称至少 2 个字符").max(20, "最多 20 个字符"),
confirmPassword: z.string(),
// input 收上来的都是 string,coerce 先转数字再校验
age: z.coerce.number().int("年龄必须是整数").min(18, "未满 18 岁不能注册"),
}).refine((d) => d.password === d.confirmPassword, {
message: "两次输入的密码不一致",
path: ["confirmPassword"], // 错误挂在哪个字段上
});
// 类型从 schema 反推,DTO 类型不用手写两遍(第 28 篇展开)
export type RegisterValues = z.infer<typeof registerSchema>;
const form = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
defaultValues: { email: "", password: "", nickname: "", confirmPassword: "", age: 0 },
});
看懂三件事:
- 声明式:数据形状和校验规则写在同一处,register 退回「绑定字段」的本职,校验时机统一交给 RHF 调度。
- z.coerce.number():register 收上来的值永远是字符串,用户敲
25你拿到的是"25"——先强转再校验,前端表单最经典的暗坑之一。 - refine:「两次密码一致」这类跨字段规则终于有家了,还能用 path 把错误精确指到字段。
和后端的对应关系几乎是逐条映射的:
| 后端注解(Bean Validation) | Zod 等价写法 | 备注 |
|---|---|---|
@NotBlank / @NotNull | z.string().min(1, "...") | 前端没有 null 输入,「空」就是空串 |
@Email | z.string().email() | 格式类规则一一对应 |
@Size(min=8, max=20) | z.string().min(8).max(20) | |
@Pattern(regexp=...) | z.string().regex(/.../) | |
@Min(18) / @Max(99) | z.number().min(18).max(99) | 先 z.coerce.number() 转型 |
@AssertTrue(类级跨字段) | .refine(...) | 两次密码一致就靠它 |
z.object 的 shape 是字段,链式调用是注解,refine 是类级别的 @AssertTrue——registerSchema 的角色就是 RegisterRequest DTO 加上 Bean Validation。再往前想一步:同一套规则前后端各写一遍是浪费,monorepo 共享 schema、或按 OpenAPI 文档双端生成,就能让「一份规则两处执行」变成「一份代码两处执行」。
提交后端:loading、服务端错误与重置
前端校验是第一道闸,终点是发请求(fetch 的细节第 32 篇展开,这里只看表单侧的配合)。完整注册表单如下:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registerSchema, type RegisterValues } from "./registerSchema";
function RegisterForm() {
const {
register, handleSubmit, setError, reset,
formState: { errors, isSubmitting },
} = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
defaultValues: { email: "", password: "", nickname: "", confirmPassword: "", age: 0 },
});
const onSubmit = async (values: RegisterValues) => {
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const body = await res.json();
if (body.field === "email") {
// 服务端说邮箱被占用:精确回流到对应字段
setError("email", { message: "该邮箱已被注册" });
} else {
// 整表级错误放 root 槽位,不挂到任何 input 上
setError("root", { message: body.message ?? "注册失败,请稍后再试" });
}
return;
}
reset(); // 成功:清空表单,回到 defaultValues
} catch {
setError("root", { message: "网络异常,请检查连接后重试" });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("email")} placeholder="邮箱" />
{errors.email && <p className="error">{errors.email.message}</p>}
// password / nickname / confirmPassword / age 同款写法,略
{errors.root && <p className="error">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "注册中…" : "注册"}
</button>
</form>
);
}
四个值得停的点:
- onSubmit 是 async,handleSubmit 会等它落定:期间
isSubmitting为 true,按钮禁用防重复提交——又一个你不用自己维护的 useState。 - setError("email"):邮箱唯一性这类校验只有服务端能做,返回后精确挂回对应输入框——前端、后端两道校验,汇进同一套 errors 管道。
- setError("root"):RHF 约定的整表级错误槽位——验证码错误、风控提示这类「不属于任何字段」的信息放这里。
- reset():成功后回到 defaultValues;配合
isSubmitSuccessful可以顺手做成功提示。
错误什么时候冒出来:校验触发时机
最后一块拼图是体验细节:校验几时跑?默认 mode="onSubmit"——点提交才校验,第一次提交前用户得不到任何反馈;提交过后进入 reValidateMode(默认 onChange),改一个字验一次。
useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
mode: "onBlur", // 离开输入框就校验
reValidateMode: "onChange", // 提交过后,边改边校验
});
| mode | 校验时机 | 适用 |
|---|---|---|
onSubmit(默认) | 点提交才校验 | 最省性能,但反馈最晚 |
onBlur | 字段失焦时 | 推荐:打字不被打扰,离开就知对错 |
onChange | 每敲一键就校验 | 即时但吵,重校验会卡打字 |
onTouched | 首次失焦后转 onChange | 折中方案,体验最佳 |
另有 trigger() 可以不等提交主动触发校验(单字段或全表),是「下一步」式分步表单的标配。
- 表单 = 高频、局部、带校验的复杂客户端状态;裸 useState 三宗罪:每字段一个 state、每键一次重渲染、校验散落。
- React Hook Form 用 register 登记非受控 input:值住 DOM、每键零重渲染;handleSubmit 校验通过才放行业务函数。
- Zod schema 是前端版 Bean Validation:规则唯一来源、z.infer 反推类型、refine 收留跨字段规则、coerce 处理字符串转型。
- 提交闭环:isSubmitting 防重复、setError 把服务端错误精确回流字段(整表错误放 root)、reset 复位;服务端校验永远不能省。
- mode 控制校验触发时机,onBlur / onTouched 是体验优选。
本章回顾
这一篇把第 17 篇分类里最闹腾的客户端状态收服了:RHF 负责收集(非受控、零重渲染),Zod 负责规则(声明式、可复用),两者合体就是 @ModelAttribute + Bean Validation 的前端镜像。但注册成功那一刻的登录态、填了一半的表单草稿,都指向同一个诉求——刷新页面别丢。卷三的最后一站,我们把状态「落盘」并连进多端:下一篇第 22 篇,状态持久化与多端同步。
Comments · 评论