Chapter 05

表单与验证

用 shadcn 的 Form 组件把 react-hook-form 与 Zod 缝合起来,实现类型安全、可访问、错误自动展示的表单。

5.1 关键概念

react-hook-form (RHF)
高性能表单库,用非受控为主的策略减少重渲染,通过 useForm 管理状态、校验、提交,register/Controller 接入字段。
Zod
TypeScript 优先的 schema 校验库,用 z.object({...}) 定义结构与规则,同时能 z.infer 出 TS 类型,一份 schema 校验+类型两用。
zodResolver
@hookform/resolvers 提供的桥,把 Zod schema 接进 RHF 的 resolver,提交/失焦时自动跑 Zod 校验并把错误塞回表单。
shadcn Form
一层薄封装(基于 RHF 的 Context),提供 Form/FormField/FormItem/FormLabel/FormControl/FormDescription/FormMessage,自动接线 id、aria、错误展示。
FormField
包裹 RHF Controller,用 name 绑定字段、render 返回具体输入组件,把 field(value/onChange/onBlur/ref)交给你。
FormMessage
自动读取当前字段的校验错误并渲染,无错误则不渲染,还负责设置 aria-describedby 关联。
defaultValues
useForm 的初始值,决定字段受控起点,也是"重置表单"的目标;对 TS 类型推断很重要。

5.2 安装

$ npx shadcn@latest add form input button
$ npm i react-hook-form zod @hookform/resolvers

5.3 定义 Zod schema

// schema:一份定义,校验与类型两用
import { z } from "zod";

const formSchema = z.object({
  username: z.string().min(2, "至少 2 个字符").max(20),
  email: z.string().email("邮箱格式不正确"),
  age: z.coerce.number().min(18, "必须年满 18 岁"),
  bio: z.string().max(160).optional(),
});

type FormValues = z.infer<typeof formSchema>;

5.4 用 useForm + zodResolver

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const form = useForm<FormValues>({
  resolver: zodResolver(formSchema),
  defaultValues: { username: "", email: "", age: 18, bio: "" },
});

function onSubmit(values: FormValues) {
  // values 已校验且类型安全
  console.log(values);
}

5.5 组装 Form JSX

import {
  Form, FormControl, FormDescription, FormField,
  FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
    <FormField
      control={form.control}
      name="username"
      render={({ field }) => (
        <FormItem>
          <FormLabel>用户名</FormLabel>
          <FormControl>
            <Input placeholder="shadcn" {...field} />
          </FormControl>
          <FormDescription>这会公开显示。</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />

    <FormField
      control={form.control}
      name="email"
      render={({ field }) => (
        <FormItem>
          <FormLabel>邮箱</FormLabel>
          <FormControl><Input type="email" {...field} /></FormControl>
          <FormMessage />
        </FormItem>
      )}
    />

    <Button type="submit">提交</Button>
  </form>
</Form>
自动接线的魔法

FormFieldfield(value/onChange/onBlur/ref)交给你 spread 进 Input;FormItem 生成唯一 id 并让 Label、Control、Message 通过 aria 属性互相关联;FormMessage 自动显示该字段的 Zod 错误。你几乎不用手写 aria。

5.6 非文本字段:Select / Checkbox

<FormField
  control={form.control}
  name="role"
  render={({ field }) => (
    <FormItem>
      <FormLabel>角色</FormLabel>
      <Select onValueChange={field.onChange} defaultValue={field.value}>
        <FormControl>
          <SelectTrigger><SelectValue placeholder="选择角色" /></SelectTrigger>
        </FormControl>
        <SelectContent>
          <SelectItem value="admin">管理员</SelectItem>
          <SelectItem value="user">普通用户</SelectItem>
        </SelectContent>
      </Select>
      <FormMessage />
    </FormItem>
  )}
/>
受控组件要接 onValueChange

Select/Checkbox/Switch 不是原生 input,不能直接 {...field}。要把 field.onChange 接到 onValueChange/onCheckedChange,把 field.value 接到 value/checked

5.7 校验时机与模式

const form = useForm({
  resolver: zodResolver(schema),
  mode: "onBlur",        // onSubmit(默认) / onChange / onBlur / onTouched
  reValidateMode: "onChange", // 出错后重新校验的时机
});

5.8 跨字段校验与服务端错误

// 跨字段:确认密码
const schema = z.object({
  password: z.string().min(8),
  confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
  message: "两次密码不一致",
  path: ["confirm"],   // 错误挂到 confirm 字段
});

// 服务端返回的错误手动塞回
form.setError("email", { message: "该邮箱已被注册" });

5.9 提交态与重置

const { isSubmitting, isValid } = form.formState;

<Button type="submit" disabled={isSubmitting}>
  {isSubmitting ? "提交中…" : "注册"}
</Button>

// 提交成功后重置
form.reset();

5.10 小结与下一步