首页 / React 学习笔记 / 21

REACT · Vol.III · LESSON 21 · 状态管理

表单状态管理(对比 Spring 表单绑定)

实战#表单#实战

表单,是一类最难伺候的客户端状态

第 20 篇给全局状态做完选型时留了个尾巴:表单输入这类高频临时值,连状态提升都要慎重,更别提塞进 Zustand。这一篇专门把尾巴收掉。

先给表单状态画个像,它同时踩中三个特征:

  • 高频:每敲一个字符就变一次,变更频率比购物车点击高一个量级;
  • 局部:email、密码这些值出了表单没人关心,放进全局 store 纯属污染;
  • 带校验:必填、格式、长度、两次密码一致……规则成打,还会互相联动。

按第 17 篇的分类它是纯客户端状态,但裸 useState 很快撞墙:

RegisterForm.tsx · 裸 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>
  );
}

三宗罪数完:

  1. 每字段一个 state:声明一份、接线一份、提交时再拼一份,全是样板;
  2. 每键一次重渲染:setEmail 触发整个 RegisterForm 重渲染,字段一多打字都卡(怎么量化留给第 37 篇);
  3. 校验散落:onChange 里查一次、提交前查一次;「两次密码一致」这种跨字段规则更是没地方放。
想象这个组件长到 15 个字段、20 条规则的样子——大多数人「React 表单代码很丑」的印象由此而来。不是 React 丑,是裸写的姿势不对。
后端类比:手写 getParameter vs @ModelAttribute

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)把这条路工程化:

RegisterForm.tsx · RHF 版:没有一份字段级 useState
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>
  );
}

三个关键角色:

  1. register("email"):返回 { name, ref, onChange, onBlur } 展开到 input 上,本质是把 input 的 DOM ref「登记」进 RHF 的内部仓库——机制上就是第 12 篇 useRef 那套 ref + 事件转发。值从此不经过 React state。
  2. 非受控 = 值住 DOM:打字时只有 DOM 自己在变,React 全程不参与,每键零重渲染。唯一被订阅的是 errors——它真的变了,组件才重渲染。
  3. handleSubmit(onSubmit):提交拦截器——先跑校验,通过才把拼好的 values 交给 onSubmit;不通过根本进不了业务函数。连 e.preventDefault() 都替你调了。

受控与非受控的完整对决留给第 24 篇,这里先记结论:信息收集型表单,非受控收集是性能与简洁的默认解

后端类比:register + handleSubmit = @ModelAttribute + @Valid

register 的参数就是绑定字段名(相当于请求参数名),handleSubmit 是校验闸门,onSubmit(values) 才是真正进业务逻辑的 Controller 方法体——DTO 绑定、校验拦截这两步,和 Spring 干的是同一件事,只是发生在浏览器里。

Zod schema:把 Bean Validation 搬进前端

上一站留了个疙瘩:校验规则写在 register 的参数里,又跟着组件散落了,跨字段规则依然没地方放。解法和后端一模一样——把校验从组件里抽出来,声明式集中成一份 schema。装依赖:npm install zod @hookform/resolvers

registerSchema.ts · 校验规则的唯一来源
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>;
RegisterForm.tsx · 组件里只剩一行接线
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 / @NotNullz.string().min(1, "...")前端没有 null 输入,「空」就是空串
@Emailz.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(...)两次密码一致就靠它
后端类比:registerSchema = DTO + 一摞校验注解

z.object 的 shape 是字段,链式调用是注解,refine 是类级别的 @AssertTrue——registerSchema 的角色就是 RegisterRequest DTO 加上 Bean Validation。再往前想一步:同一套规则前后端各写一遍是浪费,monorepo 共享 schema、或按 OpenAPI 文档双端生成,就能让「一份规则两处执行」变成「一份代码两处执行」。

就算后端已经用 Bean Validation 全量校验过,前端这份 schema 也不能省——省掉的不是代码,是用户的等待:本地毫秒级反馈,和一次几百毫秒的网络往返,是两种体验。

提交后端:loading、服务端错误与重置

前端校验是第一道闸,终点是发请求(fetch 的细节第 32 篇展开,这里只看表单侧的配合)。完整注册表单如下:

RegisterForm.tsx · 完整闭环:提交、错误、loading、重置
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>
  );
}

四个值得停的点:

  1. onSubmit 是 async,handleSubmit 会等它落定:期间 isSubmitting 为 true,按钮禁用防重复提交——又一个你不用自己维护的 useState。
  2. setError("email"):邮箱唯一性这类校验只有服务端能做,返回后精确挂回对应输入框——前端、后端两道校验,汇进同一套 errors 管道。
  3. setError("root"):RHF 约定的整表级错误槽位——验证码错误、风控提示这类「不属于任何字段」的信息放这里。
  4. reset():成功后回到 defaultValues;配合 isSubmitSuccessful 可以顺手做成功提示。
警惕「前端校验过了就安全」的错觉:浏览器里的任何校验都能被 F12 或 curl 绕过。前端校验管体验(快),服务端校验管底线(对)——就像 @Valid 拦不住直接调用 service 的代码,该有的防线一层不能少。

错误什么时候冒出来:校验触发时机

最后一块拼图是体验细节:校验几时跑?默认 mode="onSubmit"——点提交才校验,第一次提交前用户得不到任何反馈;提交过后进入 reValidateMode(默认 onChange),改一个字验一次。

RegisterForm.tsx · 推荐配置:失焦即验,改后即验
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 · 评论