#React#TypeScript

React 表单最佳实践

表单是前端最繁琐的活——验证、错误提示、动态字段、提交。本文讲清 React 表单的完整实践:React Hook Form vs Formik、zod 校验、受控 vs 非受控、Server Actions 提交、以及复杂数据(数组/嵌套)怎么处理,让你的表单既简洁又健壮。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月29日·更新于 2026年7月23日

一、表单为什么这么难

写过两年业务的前端都有一个共识:页面切得再漂亮,表单一出问题全是事故。

表单的难度不在"输入框 + 提交按钮",而在于它把前端几乎所有麻烦事拧到了一起:

  • 校验:前端校验、后端校验,还要保持一致;
  • 错误提示:每个字段怎么错、错了说什么、什么时候显示;
  • 动态字段:根据某个值显隐其他字段、数组增删、联动;
  • 提交:loading、disabled、失败回滚、重复提交、网络中断;
  • 复用:同一份逻辑要在新增、编辑、多步表单里复用。

更要命的是,React 没有官方表单方案。<input value={x} onChange={...} /> 这种受控写法看起来天经地义,但一旦字段超过 10 个,每次按键都会触发整棵表单重渲染,输入卡顿肉眼可见。

所以处理表单的第一原则不是"用什么库",而是:选对工具,把表单的心智负担减一半。下面我按选型、校验、状态、提交、错误、复杂场景、复用这条线,把实战讲透。

二、选型:React Hook Form vs Formik

这两者是社区主流,但它们的设计哲学完全不同。

Formik 是受控路线的代表。每个字段通过 handleChange / values 绑定,值存在 React state 里。优点是模型简单、调试直观;缺点是每键一次 setState,字段一多就卡,需要手动 memo 或者拆组件优化。

React Hook Form(RHF) 走非受控路线。用 register 把 ref 注册到原生 <input>,数据不进 React state,提交时一次性读取。性能几乎是 Formik 的数倍,且默认 reValidateMode 体验更顺手。

tsx
// React Hook Form 的最小例子
import { useForm } from "react-hook-form";

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email", { required: "邮箱必填" })} />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">登录</button>
    </form>
  );
}

我的建议很直接:

  • 新项目一律用 React Hook Form,生态、性能、TS 体验全面领先;
  • Formik 只在维护老项目、或者团队已经重度依赖它的上下文里继续用;
  • 别再自己手撸"受控 + useState"的表单库了,这是重复造一个残缺的轮子。

下面所有示例默认用 RHF。

三、校验:一份 zod schema,类型和校验都给你

表单校验最大的坑是"前后端各写一遍"。TypeScript 的类型只解决"形状",解决不了"邮箱格式、长度范围、必填"。引入 zod,一份 schema 同时产出 TS 类型和运行时校验,再通过 @hookform/resolvers/zod 无缝接入 RHF。

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

const schema = z.object({
  email: z.string().min(1, "邮箱必填").email("邮箱格式不正确"),
  password: z.string().min(8, "密码至少 8 位"),
  age: z.number({ invalid_type_error: "请输入数字" }).min(18, "需年满 18 岁"),
});

type FormValues = z.infer<typeof schema>; // 类型自动推导,无需手写 interface

function RegisterForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  const onSubmit = (data: FormValues) => {
    // data 已经是校验通过的类型安全数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register("email")} />
      {errors.email && <span role="alert">{errors.email.message}</span>}
      <input type="password" {...register("password")} />
      {errors.password && <span role="alert">{errors.password.message}</span>}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      {errors.age && <span role="alert">{errors.age.message}</span>}
      <button type="submit">注册</button>
    </form>
  );
}

要点:

  1. schema 是唯一事实来源。前端表单、Server Action、后端 API 共用同一份 zod schema,改一处,三处同步;
  2. z.infer<typeof schema> 反推类型,杜绝"类型跟校验不一致"的低级 bug;
  3. 数字、日期类字段记得用 valueAsNumber / valueAsDate,否则拿到的是字符串。

四、受控 vs 非受控:别一上来就 setState

新手最常踩的坑:为了拿输入值,把每个字段都写成 const [email, setEmail] = useState("")。十个字段十个 useState,渲染爆炸。

非受控(RHF 的默认方式):通过 register 把原生 DOM ref 拿过来,值存在 DOM 里,React 不参与每次按键的重渲染。提交时 RHF 统一读取。这是大型表单不卡的关键。

受控(useState):适合真正需要"实时根据输入联动"的场景,比如输入时实时搜索、根据输入长度显示进度条。即便如此,RHF 也提供了 watch 和 useWatch 来按需订阅,不必退回 useState:

tsx
const password = watch("password");
const strength = getPasswordStrength(password); // 按需订阅,只在该字段变化时重渲染

实战原则:

  • 默认非受控,RHF 全权托管;
  • 单字段实时联动用 watch / useWatch,别滥用;
  • 整个表单状态外置到全局 store(Redux/Zustand)几乎总是错的——表单就该活在表单组件里。

五、提交:Server Actions + FormData 才是正道

Next.js App Router 时代,提交表单的最佳方式是 Server Actions + useFormState / useFormStatus,它天然支持渐进增强——即使 JS 没加载,原生 <form action> 也能提交。

tsx
// app/actions/register.ts
"use server";
import { schema } from "@/lib/schemas";

export async function registerAction(prevState: any, formData: FormData) {
  const raw = Object.fromEntries(formData.entries());
  const parsed = schema.safeParse(raw);
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }
  try {
    await createUser(parsed.data);
    return { success: true };
  } catch (e) {
    return { error: "注册失败,请稍后重试" };
  }
}

// app/register/page.tsx
import { useFormState, useFormStatus } from "react-dom";
import { registerAction } from "@/app/actions/register";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "提交中..." : "注册"}</button>;
}

export default function Page() {
  const [state, formAction] = useFormState(registerAction, null);
  return (
    <form action={formAction}>
      <input name="email" type="email" />
      {state?.errors?.email?.[0] && <p>{state.errors.email[0]}</p>}
      <SubmitButton />
    </form>
  );
}

如果是普通 SPA、或者用 API 路由,那就 handleSubmit 里 fetch,逻辑类似,但 loading / error 状态要自己用 isSubmitting 管:

tsx
const { formState: { isSubmitting }, setError } = useForm();

const onSubmit = async (data: FormValues) => {
  try {
    await fetch("/api/register", { method: "POST", body: JSON.stringify(data) });
  } catch (e) {
    setError("root", { message: "网络异常,请重试" }); // 表单级错误
  }
};

要点:

  • RHF 的 handleSubmit 会把 <button disabled={isSubmitting}> 状态直接给你,不用自己造;
  • 同一份 zod schema 在 Server Action 里再 safeParse 一次,前端校验是体验,后端校验是安全,两道闸缺一不可。

六、错误提示:字段级、表单级、可访问性

错误提示看似简单,做错了既难用又不可访问。

字段级错误:绑在每个输入下方,聚焦或失焦后立刻显示:

tsx
<input
  {...register("email")}
  aria-invalid={errors.email ? "true" : "false"}
/>
{errors.email && (
  <span role="alert" id="email-error">{errors.email.message}</span>
)}

表单级错误:提交后服务端返回的失败(比如"账号已存在""验证码失效"),统一放在表单顶部,用一个显眼的提示区:

tsx
{errors.root && <div role="alert">{errors.root.message}</div>}

可访问性要点(很多人忽略):

  • 给输入框加 aria-invalid,屏幕阅读器会读"无效";
  • 错误信息用 role="alert",保证读屏时立即播报;
  • 尽量用 aria-describedby 关联错误和输入,而不是只靠视觉位置。

这些细节在合同里没人在意,但用键盘和无障碍工具一测就现形——专业前端和糊弄事的差别就在这。

七、复杂表单:数组、嵌套、条件字段

业务表单从来不是平的。订单有多个商品项、地址有省市区联动、合同有可增减的条款——这些都要靠 RHF 的 useFieldArray 和嵌套 register。

tsx
import { useFieldArray } from "react-hook-form";

function OrderForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: { items: [{ name: "", qty: 1 }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, i) => (
        <div key={field.id}>
          <input {...register(`items.${i}.name` as const, { required: true })} />
          <input type="number" {...register(`items.${i}.qty` as const, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(i)}>删除</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: "", qty: 1 })}>加一项</button>
    </form>
  );
}

几个关键点:

  1. useFieldArray 必须给一个稳定 key={field.id},绝不用数组下标当 key——否则删除中间项时 React 会错乱;
  2. 嵌套字段名用路径字符串 items.${i}.name,TS 那边 as const 保住字面量类型;
  3. 嵌套对象同理,register("address.city") 直接走;
  4. 条件字段:显隐用 watch,校验用 zod 的 superRefine 联动判断——"选了'其他'就必须填备注"这种规则写在 schema 里最干净:
    tsx
    const schema = z.object({
      source: z.string(),
      other: z.string().optional(),
    }).superRefine((data, ctx) => {
      if (data.source === "other" && !data.other) {
        ctx.addIssue({ code: "custom", path: ["other"], message: "请填写来源说明" });
      }
    });
    

最重要的忠告:别把一个 200 行的巨型表单塞进单文件。按业务块拆 sub-form(收货信息一块、支付信息一块),每个 sub-form 是独立的 RHF 子表单或者通过 useFormContext 共享同一个 form 实例。文件拆细,可测、可维护。

八、复用:hook、schema、字段组件

最后一步是把表单从"一次性"变成"可组合"。

自定义 hook 封装:把表单逻辑抽出来,组件只管渲染。

tsx
function useProfileForm(user?: User) {
  const form = useForm<ProfileValues>({
    resolver: zodResolver(profileSchema),
    defaultValues: user ?? { email: "", name: "", bio: "" },
  });
  const save = async (data: ProfileValues) => {
    await fetch("/api/profile", { method: "PUT", body: JSON.stringify(data) });
  };
  return { form, save };
}

新增页和编辑页共用一个 hook,只是 defaultValues 不同——典型 CRUD 套路。

共享 schema:lib/schemas/profile.ts 导出 schema,前端、Server Action、测试用例都 import,校验逻辑永不漂移。

字段组件库:把"输入框 + label + 错误提示 + aria 关联"封装成 <TextField />、<SelectField />,统一设计语言和无障碍行为。一个团队里每个人手写的 input 五花八门,沉淀字段组件是表单工程化的分水岭。

tsx
function TextField({ name, label, register, error }: TextFieldProps) {
  return (
    <label>
      {label}
      <input aria-invalid={!!error} aria-describedby={`${name}-error`} {...register(name)} />
      {error && <span id={`${name}-error`} role="alert">{error.message}</span>}
    </label>
  );
}

小结

React 表单的水很深,但抓住主线就能稳:

  1. 选型:新项目首选 React Hook Form,性能和 DX 都比 Formik 强,别再用 useState 硬扛;
  2. 校验:zod schema 是唯一事实来源,类型与运行时校验一体,Server Action 复用同一份,前后端不再漂移;
  3. 状态:默认非受控,字段联动用 watch / useWatch,别把表单数据塞进全局 store;
  4. 提交:App Router 项目优先 Server Actions + FormData,天然渐进增强;isSubmitting 管按钮状态,loading 错误都不漏;
  5. 错误:字段级 + 表单级双层,aria-invalid / role="alert" 是底线,可访问性不是可选项;
  6. 复杂场景:useFieldArray 处理数组,稳定 key;superRefine 处理条件校验;巨型表单一定要拆 sub-form;
  7. 复用:hook 封装表单逻辑、共享 schema、字段组件库三件套,是团队表单工程化的地基。

金句:表单的复杂度不会消失,只会转移到没设计好的地方——要么转移到库的合理抽象里,要么转移到你下个月的 bug 列表上。

选好工具、写好 schema、管好状态、拆好组件,表单就从"前端地狱"变成可以放心交付的模块。

Related

相关推荐