Chapter 07

schema 一路贯穿到 UI

纯手写表单校验累——每个 field 都要管 value/error/dirty。React Hook Form + zodResolver 是 2026 React 表单事实标准,再加 Next.js 的 Conform + Server Actions,schema 写一次,前端后端共用。

React Hook Form + Zod

pnpm add react-hook-form @hookform/resolvers zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  email: z.string().email("邮箱格式不对"),
  password: z.string().min(8, "至少 8 位"),
});

type FormData = z.infer<typeof schema>;

export function SignInForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data: FormData) => {
    await signIn(data);   // data 已经通过 Zod 校验,类型安全
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} placeholder="邮箱" />
      {errors.email && <p>{errors.email.message}</p>}

      <input {...register("password")} type="password" placeholder="密码" />
      {errors.password && <p>{errors.password.message}</p>}

      <button disabled={isSubmitting}>登录</button>
    </form>
  );
}

10 行模板 + schema——所有校验、错误展示、类型推断都自动打通。

嵌套字段

const schema = z.object({
  user: z.object({
    name: z.string().min(1, "姓名必填"),
    age: z.coerce.number().int().min(0),
  }),
  address: z.object({
    city: z.string(),
    zip: z.string().regex(/^\d{6}$/),
  }),
});

// register 用 dot path
<input {...register("user.name")} />
<input {...register("user.age")} type="number" />
<input {...register("address.city")} />

// errors 也是嵌套
errors.user?.name?.message
errors.address?.zip?.message

处理 coerce(string → number)

// HTML input value 永远是 string —— 用 coerce 处理
const schema = z.object({
  age: z.coerce.number().int().min(18, "未满 18 岁不能注册"),
  price: z.coerce.number().nonnegative(),
});

<input {...register("age")} type="number" />
// 输入 "30" → Zod 转 30 → onSubmit 拿到的 data.age 是 number

Zod 3.x 要用 valueAsNumber 辅助,v4 用 z.coerce 直接解决。

数组字段(动态添加)

const schema = z.object({
  tags: z.array(z.string().min(1, "标签不能为空")).min(1, "至少 1 个标签"),
});

const { register, control, handleSubmit } = useForm<T>({ resolver: zodResolver(schema) });
const { fields, append, remove } = useFieldArray({ control, name: "tags" });

{fields.map((f, i) => (
  <div key={f.id}>
    <input {...register(`tags.${i}`)} />
    <button onClick={() => remove(i)}>-</button>
  </div>
))}
<button onClick={() => append("")}>+</button>

跨字段校验

const schema = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((d) => d.password === d.confirm, {
    message: "两次密码不一致",
    path: ["confirm"],
  });

// 错误会出现在 errors.confirm

default values

useForm<T>({
  resolver: zodResolver(schema),
  defaultValues: {
    name: "",
    age: 18,
    tags: [""],
  },
});

RHF 的 defaultValues 和 Zod 的 .default(...) 不是一回事——前者是 UI 初始值,后者是解析时的默认值。写 form 建议两个都用。

异步校验(邮箱查重)

const schema = z.object({
  email: z.string().email().refine(
    async (e) => !(await isEmailTaken(e)),
    "邮箱已被注册"
  ),
});

useForm<T>({
  resolver: zodResolver(schema),
  mode: "onBlur",    // ← 改成 onBlur 避免每次敲键都查 DB
});

异步 refine 在 RHF 里完全透明——zodResolver 会自动用 parseAsync。但注意把 modeonBluronTouched,不然每次 onChange 都触发异步请求。

Next.js Server Actions + Conform

pnpm add @conform-to/react @conform-to/zod zod
// schemas/signup.ts —— 前后端共用
export const SignUpSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});
// app/signup/actions.ts —— Server Action
"use server";
import { parseWithZod } from "@conform-to/zod";
import { SignUpSchema } from "@/schemas/signup";

export async function signUp(_: unknown, formData: FormData) {
  const submission = parseWithZod(formData, { schema: SignUpSchema });

  if (submission.status !== "success") {
    return submission.reply();
  }

  await createUser(submission.value);
  redirect("/dashboard");
}
// app/signup/page.tsx —— Client 表单
"use client";
import { useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { useActionState } from "react";
import { SignUpSchema } from "@/schemas/signup";
import { signUp } from "./actions";

export default function Page() {
  const [lastResult, action] = useActionState(signUp, null);
  const [form, fields] = useForm({
    lastResult,
    onValidate: ({ formData }) => parseWithZod(formData, { schema: SignUpSchema }),
    shouldValidate: "onBlur",
  });

  return (
    <form id={form.id} onSubmit={form.onSubmit} action={action}>
      <input name={fields.email.name} />
      <p>{fields.email.errors}</p>

      <input name={fields.password.name} type="password" />
      <p>{fields.password.errors}</p>

      <button>注册</button>
    </form>
  );
}

Conform 的杀手锏:同一个 schema 被客户端 onValidate 和服务端 Action 两次执行——客户端提前拦错,服务端兜底防绕过。Zod schema 就是这个"单一真源"。

TanStack Form

pnpm add @tanstack/react-form zod
import { useForm } from "@tanstack/react-form";
import { z } from "zod";

const schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

function Form() {
  const form = useForm({
    defaultValues: { name: "", email: "" },
    validators: { onChange: schema },   // Zod schema 直接喂进去
    onSubmit: async ({ value }) => {
      await submit(value);
    },
  });

  return (
    <form.Field name="name">
      {(field) => (
        <>
          <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
          {field.state.meta.errors.map((e) => <p>{e}</p>)}
        </>
      )}
    </form.Field>
  );
}

TanStack Form 2026 开始支持直接吃 Standard Schema(Zod / Valibot 都行)——不用 resolver 中间层。

三个库怎么选

React Hook FormConformTanStack Form
使用场景CSR 表单Next.js Server ActionsCSR + 强类型派
Zod 集成zodResolverparseWithZod直接 validators
渐进增强需要 JS✓ 原生 form POST需要 JS
Bundle~25KB~15KB~14KB
学习成本中(Server Action)中(types 复杂)
决策
纯前端项目 → React Hook Form(生态最好);Next.js App Router → Conform(原生 FormData + 服务端兜底);超大 TS 项目或 React Native → TanStack Form。

常见坑

1. 数字字段变成字符串

// ❌
z.number();
// 输入 "30" 会报错 Expected number

// ✓
z.coerce.number();
// RHF: <input type="number" {...register("age", { valueAsNumber: true })} />

2. Checkbox 的 on/undefined 问题

// HTML checkbox unchecked 时 FormData 里就没这个 key
const schema = z.object({
  agree: z.boolean().refine((v) => v, "必须同意条款"),
});

// RHF 里没问题(默认 false);
// Conform / FormData 要用 z.coerce.boolean() 或 z.literal("on").transform(() => true)

3. Select 空值

// <option value="">请选择</option> 会变成空串
const schema = z.object({
  role: z.enum(["admin", "user"], { message: "请选择角色" }),
  // 空串会命中 invalid_enum_value → 自定义错误里写"请选择角色"
});

4. File input

const schema = z.object({
  avatar: z
    .instanceof(File)
    .refine((f) => f.size < 5 * 1024 * 1024, "文件超过 5MB")
    .refine((f) => ["image/png", "image/jpeg"].includes(f.type), "只接受 PNG/JPG"),
});

完整例子:注册表单

const SignUpSchema = z
  .object({
    email: z.string().email("邮箱格式不对"),
    password: z.string().min(8, "至少 8 位").regex(/[A-Z]/, "需要大写字母"),
    confirm: z.string(),
    age: z.coerce.number().int().min(18, "未满 18 岁"),
    agree: z.literal(true, { errorMap: () => ({ message: "必须同意协议" }) }),
  })
  .refine((d) => d.password === d.confirm, {
    message: "两次密码不一致",
    path: ["confirm"],
  });

export function SignUpForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<
    z.infer<typeof SignUpSchema>
  >({
    resolver: zodResolver(SignUpSchema),
  });

  return (
    <form onSubmit={handleSubmit(signUp)}>
      {/* fields... */}
    </form>
  );
}

本章小结