为什么不直接 useState

3 个字段的表单 useState 够用。10+ 字段就力不从心:

  • 每个字段一个 setState → re-render 风暴
  • 校验规则散在各处
  • 错误信息 / touched / dirty 状态各自管
  • 提交时拼数据麻烦

React Hook Form(RHF)

npm i react-hook-form
import { useForm } from 'react-hook-form';

type Form = { email: string; password: string };

function Login() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<Form>();

  return (
    <form onSubmit={handleSubmit(async (data) => {
      await api.login(data);
    })}>
      <input {...register('email', { required: '必填', pattern: /^\S+@\S+$/ })} />
      {errors.email && <span>{errors.email.message as string}</span>}

      <input type="password" {...register('password', { minLength: 6 })} />
      {errors.password && <span>至少 6 位</span>}

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

核心思路:非受控 + ref 模式,输入字段不触发 re-render,性能极佳。

配 Zod 做校验

npm i zod @hookform/resolvers
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

const schema = z.object({
  email: z.string().email('邮箱格式不对'),
  password: z.string().min(6, '至少 6 位'),
  age: z.coerce.number().int().positive().optional(),
});

type Form = z.infer<typeof schema>;

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

Zod 同时给出

  • 运行时校验(前端)
  • TypeScript 类型(z.infer
  • 同一个 schema 可以用在后端 / Server Actions / API 路由

受控字段(Select、DatePicker 等)

第三方组件(如 Radix Select)大多不接受 ref,要用 Controller

import { Controller } from 'react-hook-form';

<Controller
  name="country"
  control={control}
  render={({ field }) => (
    <Select value={field.value} onValueChange={field.onChange}>
      <SelectItem value="cn">中国</SelectItem>
      <SelectItem value="us">美国</SelectItem>
    </Select>
  )}
/>

异步校验

<input {...register('username', {
  validate: async (v) => {
    const r = await fetch(`/api/check?u=${v}`);
    const { exists } = await r.json();
    return exists ? '用户名已被注册' : true;
  },
})} />

Server Actions + Form(Next.js 现代写法)

'use server';
import { z } from 'zod';

const schema = z.object({ email: z.string().email() });

export async function subscribe(prev: any, formData: FormData) {
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: parsed.error.flatten() };
  await db.subscriber.create({ data: parsed.data });
  return { success: true };
}
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? '提交中…' : '订阅'}</button>;
}

function Form() {
  // React 19+:useActionState 从 'react' 导入(不是 react-dom 的 useFormState——那个已废弃)
  const [state, action, isPending] = useActionState(subscribe, null);
  return (
    <form action={action}>
      <input name="email" />
      {state?.error && <p>{JSON.stringify(state.error)}</p>}
      <SubmitButton />
    </form>
  );
}

不需要 RHF / Zod 客户端集成,整个表单不需要 useState

三种方案选哪个

方案 适用
useState 直管 1-3 字段
React Hook Form + Zod 客户端复杂表单(10+ 字段、动态字段、复杂校验)
Server Actions + Zod Next.js / 表单大多是提交即结束

中等复杂度(5-10 字段、需要实时校验)→ RHF + Zod 仍是最稳。

常见模式

多步表单useFieldArray 管动态字段;上下步用 setValue / getValues / trigger(['fieldA', 'fieldB']) 校验当前步。

文件上传:必须是非受控,<input type="file" {...register('file')} />,提交时 formData.append('file', data.file[0])

错误聚焦useForm({ shouldFocusError: true })——校验失败自动 focus 第一个错误。

→ 下一篇 动画