React 表单处理最佳实践

中等 🟡React 生态
6 个标签
预计阅读时间:94 分钟
React表单状态管理验证React Hook FormZod

React 表单处理最佳实践

表单是用户与应用交互的重要方式,表单处理涉及状态管理、验证、错误处理、提交等多个方面。React 提供了受控组件和非受控组件两种表单处理方式,同时社区也提供了丰富的表单处理库,开发者可以根据需求选择合适的方案。

可以把表单想象成一次"对话":用户填写、应用即时反馈、双方反复确认,直到信息完整且合法才最终"成交"(提交)。做得好的表单让用户几乎察觉不到它的存在;做得差的表单则会因为"填完才报错""填一半清空""重复提交"等问题赶走用户。据行业经验,注册/结账表单每减少一个不必要的字段、每优化一处报错时机,转化率都可能提升数个百分点,表单质量直接关系到业务收入。

为什么表单值得单独讲

状态量大:一个中等复杂度的表单可能有十几个字段,每个字段都有值、是否被触碰过(touched)、是否出错(error)、是否正在校验等多重状态。
验证时机复杂:输入时校验、失焦校验、提交校验,还要处理异步校验(如"用户名是否已被占用")。
性能敏感:受控组件下每敲一个键都会触发重渲染,字段一多、组件一重,就会卡顿。
交互细节多:防抖、禁用提交按钮、加载态、错误聚焦、成功提示、防重复提交……细节决定体验。

表单实现方式详解

受控组件:

受控组件是指表单值由 React 状态控制的组件。每个表单元素都有一个对应的状态,通过 onChange 事件更新状态,通过 value 属性设置表单值。可以把它理解为"React 状态是唯一真相来源(single source of truth),DOM 只是状态的镜像"。受控组件的优势在于:可以实时验证用户输入、可以动态修改输入值(如自动转大写、格式化手机号)、可以禁用提交按钮直到表单有效、可以实现复杂的联动逻辑。受控组件的缺点是对于大型表单,需要管理大量状态,每次输入都会重渲染,代码可能变得冗长且有性能压力。

非受控组件:

非受控组件是指表单值由 DOM 自己管理的组件,通过 ref 在需要时(通常是提交时)读取表单值。非受控组件更接近传统 HTML 表单的处理方式,"平时不管、用时再取"。非受控组件的优势在于:代码简洁、不需要为每个字段维护 state、输入时不触发 React 重渲染因而性能好、天然适合文件上传(file input 只能非受控)。非受控组件的缺点是难以实现实时验证和动态修改输入值。

第三方表单库:

React Hook Form 是高性能的表单库,采用以非受控为主、按需订阅的方式,最大限度地减少不必要的重新渲染。Formik 是老牌功能丰富的表单库,提供了完整的受控式表单状态管理、验证、错误提示解决方案,但在大型表单下重渲染较多。Zod 是 TypeScript 优先的验证库(schema validation),可以与 React Hook Form 配合使用,提供从表单到类型的端到端类型安全。

| 方案 | 心智模型 | 重渲染成本 | 适用场景 |

| --- | --- | --- | --- |

| 受控组件 | 手动管理每个 state | 高(每次输入全表单渲染) | 字段少、需强联动/实时格式化 |

| 非受控组件 | ref 提交时读取 | 极低 | 简单表单、文件上传 |

| React Hook Form | 非受控 + 订阅 | 低(字段级隔离) | 中大型表单,性能敏感 |

| Formik | 受控集中管理 | 中到高 | 中小型表单,生态成熟 |

代码示例

javascriptCode
// 受控组件:状态即真相来源,支持实时验证
function ControlledForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validateField = (name, value) => {
    let error = '';
    switch (name) {
      case 'username':
        if (!value) error = 'Username is required';
        else if (value.length < 3) error = 'Username must be at least 3 characters';
        break;
      case 'email':
        if (!value) error = 'Email is required';
        else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) error = 'Invalid email';
        break;
      case 'password':
        if (!value) error = 'Password is required';
        else if (value.length < 8) error = 'Password must be at least 8 characters';
        break;
    }
    return error;
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    // 只有被触碰过的字段才在输入时报错,避免一上来满屏红字
    if (touched[name]) {
      setErrors(prev => ({ ...prev, [name]: validateField(name, value) }));
    }
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(prev => ({ ...prev, [name]: validateField(name, value) }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    // 提交时校验所有字段
    const nextErrors = {};
    Object.keys(formData).forEach(key => {
      nextErrors[key] = validateField(key, formData[key]);
    });
    setErrors(nextErrors);
    setTouched({ username: true, email: true, password: true });

    if (Object.values(nextErrors).every(msg => !msg)) {
      console.log('Form submitted:', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Username</label>
        <input name="username" value={formData.username}
          onChange={handleChange} onBlur={handleBlur} />
        {touched.username && errors.username && <span>{errors.username}</span>}
      </div>
      <div>
        <label>Email</label>
        <input name="email" type="email" value={formData.email}
          onChange={handleChange} onBlur={handleBlur} />
        {touched.email && errors.email && <span>{errors.email}</span>}
      </div>
      <div>
        <label>Password</label>
        <input name="password" type="password" value={formData.password}
          onChange={handleChange} onBlur={handleBlur} />
        {touched.password && errors.password && <span>{errors.password}</span>}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}
javascriptCode
// 非受控组件:ref 提交时读取,天然支持文件上传
function UncontrolledForm() {
  const usernameRef = useRef(null);
  const emailRef = useRef(null);
  const fileRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    const formData = {
      username: usernameRef.current.value,
      email: emailRef.current.value,
      file: fileRef.current.files[0] // file input 只能非受控
    };
    console.log('Form submitted:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Username</label>
        <input ref={usernameRef} defaultValue="" />
      </div>
      <div>
        <label>Email</label>
        <input ref={emailRef} type="email" defaultValue="" />
      </div>
      <div>
        <label>File</label>
        <input ref={fileRef} type="file" />
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}
javascriptCode
// React 19 的表单 Action + useActionState(服务端/客户端一体化)
// action 返回的对象会成为下一次的 state,pending 由框架托管
function NewsletterForm() {
  const [state, formAction, isPending] = useActionState(
    async (prevState, formData) => {
      const email = formData.get('email');
      if (!email) return { error: 'Email is required' };
      try {
        await fetch('/api/subscribe', { method: 'POST', body: formData });
        return { success: true };
      } catch {
        return { error: 'Subscription failed, try again' };
      }
    },
    { }
  );

  return (
    <form action={formAction}>
      <input name="email" type="email" placeholder="you@example.com" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Subscribing...' : 'Subscribe'}
      </button>
      {state.error && <span>{state.error}</span>}
      {state.success && <span>Subscribed!</span>}
    </form>
  );
}
javascriptCode
// React Hook Form 基础用法:非受控 + 内置校验,重渲染极少
import { useForm } from 'react-hook-form';

function ReactHookForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset
  } = useForm({
    mode: 'onBlur', // 校验时机:失焦校验,兼顾体验与性能
    defaultValues: { username: '', email: '', password: '' }
  });

  const onSubmit = async (data) => {
    try {
      await fetch('/api/register', {
        method: 'POST',
        body: JSON.stringify(data)
      });
      reset();
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Username</label>
        <input {...register('username', {
          required: 'Username is required',
          minLength: { value: 3, message: 'Username must be at least 3 characters' }
        })} />
        {errors.username && <span>{errors.username.message}</span>}
      </div>
      <div>
        <label>Email</label>
        <input type="email" {...register('email', {
          required: 'Email is required',
          pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email address' }
        })} />
        {errors.email && <span>{errors.email.message}</span>}
      </div>
      <div>
        <label>Password</label>
        <input type="password" {...register('password', {
          required: 'Password is required',
          minLength: { value: 8, message: 'Password must be at least 8 characters' }
        })} />
        {errors.password && <span>{errors.password.message}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}
javascriptCode
// React Hook Form + Zod:一份 schema 同时提供校验和 TS 类型
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  username: z.string().min(3, 'Username must be at least 3 characters'),
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword']
});

// 从 schema 直接推导出表单类型,改 schema 类型自动同步
type FormValues = z.infer<typeof schema>;

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

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register('username')} />
      {errors.username && <span>{errors.username.message}</span>}
      <input type="email" {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}
      <input type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
      <button type="submit">Submit</button>
    </form>
  );
}
javascriptCode
// 异步校验:用户名是否已被占用(配合防抖)
import { useForm } from 'react-hook-form';

function SignupWithAsyncCheck() {
  const { register, handleSubmit, formState: { errors, isValidating } } = useForm({
    mode: 'onBlur'
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register('username', {
        required: 'Username is required',
        validate: async (value) => {
          const res = await fetch(`/api/check-username?u=${value}`);
          const { available } = await res.json();
          return available || 'Username already taken';
        }
      })} />
      {isValidating && <span>Checking...</span>}
      {errors.username && <span>{errors.username.message}</span>}
      <button type="submit">Submit</button>
    </form>
  );
}
javascriptCode
// 复杂表单:动态字段(可增删的明细行),如订单、简历、问卷
import { useForm, useFieldArray } from 'react-hook-form';

function DynamicForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: { items: [{ name: '', quantity: 1 }] }
  });

  const { fields, append, remove } = useFieldArray({ control, name: 'items' });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.name`)} placeholder="Item name" />
          <input type="number" {...register(`items.${index}.quantity`)} placeholder="Quantity" />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: '', quantity: 1 })}>
        Add Item
      </button>
      <button type="submit">Submit</button>
    </form>
  );
}

真实案例:电商结账表单

设想一个电商结账页,包含收货信息、优惠券、支付方式共约 15 个字段。最初团队用受控组件实现,结果发现用户在填写地址时,每敲一个字符整个结账页(包括价格明细、推荐商品)都重渲染,低端安卓机上输入明显掉字,结账转化率偏低。

改造分三步:第一,切换到 React Hook Form,字段级订阅让输入只更新对应字段,重渲染次数从"每次输入全页渲染"降到"几乎为零";第二,用 Zod 定义 schema,把手机号、邮编、优惠券格式等规则集中管理并推导出 TS 类型;第三,加入防抖异步校验优惠券、提交时禁用按钮防重复下单、失败时自动聚焦第一个错误字段。改造后移动端输入卡顿消失,表单填写完成率明显提升。

javascriptCode
// 提交防重复 + 错误自动聚焦
function CheckoutForm() {
  const { register, handleSubmit, setFocus, formState: { errors, isSubmitting } } = useForm();

  const onValid = async (data) => {
    await placeOrder(data); // isSubmitting 期间按钮禁用,天然防重复提交
  };

  const onInvalid = (formErrors) => {
    // 聚焦到第一个出错的字段,提升可用性
    const firstError = Object.keys(formErrors)[0];
    if (firstError) setFocus(firstError);
  };

  return (
    <form onSubmit={handleSubmit(onValid, onInvalid)}>
      <input {...register('phone', { required: '请填写手机号' })} />
      {errors.phone && <span>{errors.phone.message}</span>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '提交订单'}
      </button>
    </form>
  );
}

表单验证策略

客户端验证:

客户端验证可以提供即时反馈,减少服务器负担。常见的验证时机包括:即时验证(onChange)、失焦验证(onBlur)、提交验证(onSubmit)。经验法则是"错误晚报,正确早报"——首次填写用 onBlur 校验(别在用户还没写完就报错),一旦某字段出过错就改为 onChange 实时校验(让用户看到修正即时生效),这也是 React Hook Form 的默认策略(reValidateMode: 'onChange')。

| 校验时机 | 触发点 | 优点 | 缺点 |

| --- | --- | --- | --- |

| onChange | 每次输入 | 反馈最即时 | 过早报错、性能压力大 |

| onBlur | 失焦时 | 体验好,不打扰 | 反馈略滞后 |

| onSubmit | 提交时 | 实现简单 | 错误集中、体验差 |

服务器端验证:

服务器端验证是最终验证保障,防止恶意提交和处理复杂的验证逻辑(如唯一性约束、库存校验、风控)。永远不要信任客户端校验——它只是体验优化,安全底线必须由服务端守。服务器端验证应该返回结构化的错误信息(哪个字段、什么原因),前端将其映射回对应字段展示。

javascriptCode
// 把服务端返回的字段级错误回填到 React Hook Form
async function onSubmit(data) {
  const res = await fetch('/api/register', {
    method: 'POST', body: JSON.stringify(data)
  });
  if (!res.ok) {
    const { fieldErrors } = await res.json();
    // fieldErrors 形如 { email: 'already registered' }
    Object.entries(fieldErrors).forEach(([name, message]) => {
      setError(name, { type: 'server', message });
    });
  }
}

性能优化

减少重新渲染:

使用 React Hook Form 的 Controller 组件包裹受控的第三方 UI 组件(如 antd Select、MUI DatePicker),可将其状态隔离在局部,避免整个表单重渲染。对于超大型表单,可将表单按业务拆分为多个子组件或分步(stepper),每步只渲染当前部分。

javascriptCode
// 用 Controller 桥接受控的第三方组件,隔离重渲染
import { useForm, Controller } from 'react-hook-form';
import { Select } from 'antd';

function ProfileForm() {
  const { control, handleSubmit } = useForm();
  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        name="country"
        control={control}
        rules={{ required: '请选择国家' }}
        render={({ field }) => (
          <Select {...field} options={[{ value: 'cn', label: '中国' }]} />
        )}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

防抖和节流:

对于搜索输入、异步校验等需要频繁触发的场景,使用防抖(debounce)延迟触发;对于滚动加载等场景,使用节流(throttle)限制频率。

javascriptCode
// 防抖搜索:输入停止 300ms 后才真正查询
function SearchInput({ onSearch }) {
  const debounced = useMemo(
    () => debounce((value) => onSearch(value), 300),
    [onSearch]
  );
  // 卸载时取消未执行的防抖任务,防止内存泄漏
  useEffect(() => () => debounced.cancel(), [debounced]);

  return <input onChange={(e) => debounced(e.target.value)} />;
}

批量更新与集中管理:

React 18 的自动批处理会将同一事件内的多次 setState 合并为一次渲染。对于状态间强联动的复杂表单,可用 useReducer 集中管理,把"如何更新"收敛到 reducer 里,让组件更清晰、更易测试。

javascriptCode
// useReducer 集中管理表单状态
function formReducer(state, action) {
  switch (action.type) {
    case 'change':
      return { ...state, values: { ...state.values, [action.name]: action.value } };
    case 'setError':
      return { ...state, errors: { ...state.errors, [action.name]: action.error } };
    case 'reset':
      return action.initial;
    default:
      return state;
  }
}

function ReducerForm({ initial }) {
  const [state, dispatch] = useReducer(formReducer, initial);
  const onChange = (e) =>
    dispatch({ type: 'change', name: e.target.name, value: e.target.value });
  return <form>{/* 字段绑定 onChange */}</form>;
}

常见坑

受控组件的 value 从 undefined 变为字符串:React 会警告"从非受控切换到受控"。给受控输入一个明确的初始值(空字符串而非 undefined)。
onChange 里直接改 state 对象:忘记用不可变更新(`{ ...prev, [name]: value }`)会导致状态不更新或引用问题。
一上来满屏红色错误:初始就展示所有 required 错误极其劝退。用 touched/dirty 控制错误显示时机。
提交按钮不禁用:网络慢时用户狂点导致重复下单。用 isSubmitting 禁用按钮。
只做前端校验:把前端校验当安全边界,服务端不校验,极易被绕过。
大表单全用受控 + 无拆分:低端设备输入卡顿的头号原因。改用 RHF 或分步渲染。
file input 想做受控:文件输入无法受控,只能用 ref/非受控。

最佳实践

中大型表单优先选 React Hook Form + Zod,兼顾性能与端到端类型安全。
校验时机遵循"首次 onBlur、出错后 onChange",错误信息具体可操作。
服务端始终校验,并把字段级错误映射回表单展示。
提交期间禁用按钮、展示加载态、失败聚焦首个错误字段。
用 Controller 桥接受控的第三方 UI 组件,隔离重渲染。
频繁触发的校验/搜索加防抖,并在卸载时清理。
关注无障碍:label 关联 input、错误用 aria-describedby、必填标注 aria-required。

受控与非受控深度对比

受控与非受控不是"二选一"的宗教之争,而是一个"控制力 vs 渲染成本"的连续光谱。理解两者的底层差异,才能在具体场景下做出正确取舍。

底层数据流差异:

受控组件:DOM 的 value 属性由 React 每次渲染写入,用户的键盘输入首先触发 onChange,React 更新 state,state 变化触发重渲染,重渲染把新值写回 DOM。整个链路走了一圈 React。一个 20 字段的受控表单,用户完整填写平均敲击 200 次键盘,就意味着约 200 次组件重渲染。
非受控组件:用户输入直接写入 DOM,React 完全不参与,只有在提交或主动 ref 读取时才"取一次快照"。同样 200 次敲击,React 重渲染次数为 0。

一张更细的对比表:

| 维度 | 受控组件 | 非受控组件 |

| --- | --- | --- |

| 真相来源 | React state | DOM |

| 输入时重渲染 | 每次都渲染 | 完全不渲染 |

| 实时校验 | 天然支持 | 需要手动 addEventListener |

| 动态格式化输入 | 容易(改 state 即可) | 困难(要操作 DOM) |

| 初始值设置 | value + 空字符串 | defaultValue |

| 重置表单 | setState 回初值 | form.reset() |

| 文件上传 | 不支持 | 唯一选择 |

| 20 字段表单敲 200 次键的重渲染 | 约 200 次 | 0 次 |

| 代码量(单字段) | 约 6 行 | 约 2 行 |

混合模式:局部受控、整体非受控。 React Hook Form 正是这一思想的集大成者——绝大多数字段走非受控(register 内部用 ref),只有需要"受控行为"的字段(如需要实时格式化的金额输入、第三方 Select)才通过 Controller 局部受控。这样既保留了必要的控制力,又把渲染成本压到最低。

tsxCode
// 混合模式示例:金额字段实时格式化(受控),其余字段非受控
import { useForm, Controller } from 'react-hook-form';

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: { title: '', amount: '' }
  });

  const formatMoney = (raw: string) => {
    // 去掉非数字,每三位加逗号
    const digits = raw.replace(/\D/g, '');
    return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  };

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {/* 普通字段:非受控,输入零重渲染 */}
      <input {...register('title')} placeholder="发票标题" />

      {/* 金额字段:受控,输入时实时格式化 */}
      <Controller
        name="amount"
        control={control}
        render={({ field }) => (
          <input
            value={field.value}
            onChange={(e) => field.onChange(formatMoney(e.target.value))}
            placeholder="0"
            inputMode="numeric"
          />
        )}
      />
      <button type="submit">开票</button>
    </form>
  );
}

原生 FormData 与渐进增强

在引入任何第三方库之前,值得先掌握浏览器原生的 FormData API。它是零依赖、包体积 0 KB 的方案,尤其适合简单表单和追求渐进增强(progressive enhancement,即 JS 未加载时表单仍可用)的场景。

tsxCode
// 纯原生 FormData:无需为每个字段维护 state
function NativeFormDataForm() {
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    // 读取单个字段
    const email = formData.get('email');
    // 读取多值字段(如多选框、多文件)
    const hobbies = formData.getAll('hobby');
    // 一次性转成普通对象
    const data = Object.fromEntries(formData.entries());

    console.log({ email, hobbies, data });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <label><input type="checkbox" name="hobby" value="reading" /> 阅读</label>
      <label><input type="checkbox" name="hobby" value="coding" /> 编程</label>
      <label><input type="checkbox" name="hobby" value="sports" /> 运动</label>
      <button type="submit">提交</button>
    </form>
  );
}

FormData 的关键优势:

零状态管理:不用 useState,不用 register,字段全靠 name 属性识别。
天然支持文件:FormData 可以直接携带 File 对象,配合 multipart/form-data 上传。
渐进增强友好:即便 React 尚未 hydrate,原生 form 的 action 属性也能让表单提交成功。

它的局限也很明显:实时校验、错误展示、字段联动都要手写,不适合复杂交互表单。经验法则是:字段 ≤ 5 且无复杂交互,用 FormData;否则上 React Hook Form。

React 19 表单能力全景

React 19 把"表单"提升为一等公民,引入了 Actions、useActionState、useFormStatus、useOptimistic 一整套原语,目标是让表单的 pending/error/optimistic 状态由框架托管,而不是开发者手写一堆 useState。

useFormStatus:读取父级 form 的提交状态。 它必须用在 form 的子组件里,让"提交按钮"这类组件无需 props 透传就能知道表单是否正在提交。

tsxCode
// 独立的提交按钮组件,自动感知 form 的 pending 状态
import { useFormStatus } from 'react-dom';

function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending, data } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? '提交中...' : children}
    </button>
  );
}

// 使用时无需任何 props,按钮自己知道该禁用
function ContactForm({ action }: { action: (fd: FormData) => Promise<void> }) {
  return (
    <form action={action}>
      <input name="name" required />
      <textarea name="message" required />
      <SubmitButton>发送消息</SubmitButton>
    </form>
  );
}

useOptimistic:乐观更新。 在请求还没返回时,先把 UI 更新成"预期成功的样子",请求失败再回滚。这能让评论、点赞、待办这类操作的感知延迟从"网络往返的 300ms~1s"降到"0ms"。

tsxCode
// 评论区乐观更新:提交瞬间就显示新评论
import { useOptimistic, useRef } from 'react';

type Comment = { id: string; text: string; sending?: boolean };

function CommentList({ comments, addComment }: {
  comments: Comment[];
  addComment: (text: string) => Promise<void>;
}) {
  const formRef = useRef<HTMLFormElement>(null);
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (state, newText: string) => [
      ...state,
      { id: 'temp-' + Date.now(), text: newText, sending: true }
    ]
  );

  const action = async (formData: FormData) => {
    const text = formData.get('text') as string;
    addOptimistic(text);       // 立即在 UI 上加一条(灰色"发送中")
    formRef.current?.reset();
    await addComment(text);     // 真正落库,成功后 comments 更新,临时项被替换
  };

  return (
    <>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>
            {c.text} {c.sending && '(发送中...)'}
          </li>
        ))}
      </ul>
      <form action={action} ref={formRef}>
        <input name="text" required />
        <button type="submit">评论</button>
      </form>
    </>
  );
}

Server Actions 与 useActionState 结合校验。 在 Next.js App Router 中,可以把 Server Action 与 Zod 校验结合,返回结构化错误交给客户端展示,实现"一份逻辑既跑在服务端又驱动 UI"。

tsxCode
// app/actions.ts —— 服务端 Action('use server')
'use server';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('邮箱格式不正确'),
  age: z.coerce.number().min(18, '需年满 18 岁')
});

export async function register(prevState: unknown, formData: FormData) {
  const parsed = schema.safeParse({
    email: formData.get('email'),
    age: formData.get('age')
  });
  if (!parsed.success) {
    // 把 Zod 错误拍平成 { field: message }
    return { errors: parsed.error.flatten().fieldErrors };
  }
  await saveUser(parsed.data);
  return { success: true };
}

// app/register-form.tsx —— 客户端组件
'use client';
import { useActionState } from 'react';
import { register } from './actions';

function RegisterForm() {
  const [state, formAction, isPending] = useActionState(register, {});
  return (
    <form action={formAction}>
      <input name="email" type="email" />
      {state?.errors?.email && <span role="alert">{state.errors.email[0]}</span>}
      <input name="age" type="number" />
      {state?.errors?.age && <span role="alert">{state.errors.age[0]}</span>}
      <button disabled={isPending}>{isPending ? '注册中...' : '注册'}</button>
      {state?.success && <p>注册成功!</p>}
    </form>
  );
}

React Hook Form 核心 API 逐个击破

React Hook Form(下称 RHF)之所以能在约 60KB 未压缩、约 9KB gzip 的体积下成为最流行的 React 表单库,靠的是"非受控 + 精准订阅"的架构。下面逐个拆解它的核心 API。

register:把字段登记为非受控。 register 返回 name、ref、onChange、onBlur 四个属性,展开到 input 上后,RHF 就通过 ref 直接读 DOM 值,输入时不触发组件重渲染。

tsxCode
// register 的第二个参数是校验规则对象
<input {...register('email', {
  required: '邮箱必填',
  maxLength: { value: 100, message: '不超过 100 字符' },
  pattern: { value: /^\S+@\S+$/, message: '格式不正确' },
  validate: {
    notGmail: (v) => !v.endsWith('@gmail.com') || '不接受 gmail 邮箱',
    notBlacklisted: async (v) => {
      const ok = await checkBlacklist(v);
      return ok || '该邮箱在黑名单中';
    }
  },
  setValueAs: (v) => v.trim().toLowerCase() // 存入前统一格式化
})} />

watch vs useWatch:订阅字段变化。 watch 在组件顶层调用会导致该组件在被订阅字段变化时重渲染;useWatch 是独立 Hook,把订阅隔离到一个小组件里,避免带着整个表单一起重渲染。做"依赖字段联动"时优先用 useWatch。

tsxCode
// 依赖字段联动:省份变化时清空并重载城市列表
import { useForm, useWatch } from 'react-hook-form';

function CityField({ control, register, setValue }: any) {
  // 只有这个小组件在 province 变化时重渲染,表单其余部分不动
  const province = useWatch({ control, name: 'province' });
  const cities = useMemo(() => getCitiesByProvince(province), [province]);

  useEffect(() => {
    setValue('city', ''); // 省份变了,城市要清空
  }, [province, setValue]);

  return (
    <select {...register('city')}>
      {cities.map((c: any) => <option key={c.code} value={c.code}>{c.name}</option>)}
    </select>
  );
}

setValue / getValues / reset:命令式操作表单。

tsxCode
const { setValue, getValues, reset, trigger } = useForm();

// setValue:程序化赋值,第三参可控制是否触发校验和标脏
setValue('phone', '13800138000', { shouldValidate: true, shouldDirty: true });

// getValues:读取当前值(不触发订阅/重渲染),适合在事件回调里"顺手取一下"
const all = getValues();               // 全部
const email = getValues('email');       // 单个
const [a, b] = getValues(['a', 'b']);   // 多个

// reset:重置到给定值(常用于"编辑表单加载完远程数据后回填")
useEffect(() => {
  if (userData) reset(userData); // 把接口返回的数据一次性填入并清除 dirty 状态
}, [userData, reset]);

// trigger:手动触发校验(多步表单里"下一步前校验当前步"必用)
const valid = await trigger(['name', 'email']);

formState:表单的全部元状态。 formState 是一个用 Proxy 实现的对象,你解构了哪个字段,RHF 才订阅哪个,没用到的状态不会引起重渲染。

| formState 字段 | 含义 | 典型用途 |

| --- | --- | --- |

| errors | 各字段错误对象 | 展示错误信息 |

| isDirty | 表单是否被修改过 | 离开页面前提示有未保存改动 |

| dirtyFields | 具体哪些字段脏了 | 只提交变更过的字段 |

| touchedFields | 哪些字段被触碰过 | 控制错误展示时机 |

| isValid | 整体是否通过校验 | 禁用提交按钮 |

| isSubmitting | 是否正在提交 | 禁用按钮、显示 loading |

| isSubmitSuccessful | 上次提交是否成功 | 成功后自动 reset |

| submitCount | 提交次数 | 多次失败后换更醒目的提示 |

mode 与 reValidateMode:校验时机的两个开关。 mode 控制首次校验时机,reValidateMode 控制字段已出错后的重新校验时机。

| mode | 首次校验触发点 | 体验特点 |

| --- | --- | --- |

| onSubmit(默认) | 点提交时 | 最不打扰,但错误集中 |

| onBlur | 字段失焦 | 推荐,填完一个校一个 |

| onChange | 每次输入 | 反馈最快,重渲染最多 |

| onTouched | 首次失焦后转 onChange | 体验与性能的平衡点 |

| all | onBlur 与 onChange 都触发 | 最严格 |

推荐组合是 mode 设为 onTouched,首次失焦才报错、之后实时纠正,与前文"错误晚报、正确早报"的原则一致。

useFieldArray 动态字段实战

useFieldArray 专门处理"可增删的字段数组",如订单明细、教育经历、多个联系人。它提供 append、prepend、insert、remove、move、swap、replace、update 等操作方法。

tsxCode
// 简历教育经历:可增删、可上移排序、每行独立校验
import { useForm, useFieldArray } from 'react-hook-form';

type EduForm = {
  educations: { school: string; degree: string; year: number }[];
};

function ResumeForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<EduForm>({
    defaultValues: { educations: [{ school: '', degree: '', year: 2020 }] }
  });

  const { fields, append, remove, move } = useFieldArray({
    control,
    name: 'educations'
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        // 注意:key 必须用 field.id,不能用 index(否则删除中间行会错乱)
        <div key={field.id}>
          <input
            {...register(`educations.${index}.school`, { required: '学校必填' })}
            placeholder="学校"
          />
          {errors.educations?.[index]?.school && (
            <span>{errors.educations[index]?.school?.message}</span>
          )}

          <input
            {...register(`educations.${index}.degree`)}
            placeholder="学位"
          />
          <input
            type="number"
            {...register(`educations.${index}.year`, { valueAsNumber: true })}
          />

          <button type="button" onClick={() => remove(index)}>删除</button>
          {index > 0 && (
            <button type="button" onClick={() => move(index, index - 1)}>上移</button>
          )}
        </div>
      ))}

      <button type="button" onClick={() => append({ school: '', degree: '', year: 2024 })}>
        添加教育经历
      </button>
      <button type="submit">保存简历</button>
    </form>
  );
}

useFieldArray 三大常见坑:

key 用 index 而非 field.id:删除或插入中间项时,React diff 会错位,导致输入框内容串行。永远用 RHF 提供的 field.id。
register 路径写错:必须用模板字符串 educations.index.school 形式,注意 index 前后的点。
默认值缺失:append 时不传完整对象,某些字段会是 undefined,触发受控/非受控警告。append 时给全字段默认值。

Zod schema 与类型推导进阶

Zod 是 TypeScript 优先的校验库(约 13KB gzip),核心价值是"一份 schema 同时产出运行时校验和编译期类型",彻底消灭"校验规则和 TS 类型两处手写、容易不同步"的问题。

typescriptCode
import { z } from 'zod';

// 丰富的 schema 定义
const userSchema = z.object({
  // 字符串约束
  username: z.string()
    .min(3, '至少 3 个字符')
    .max(20, '最多 20 个字符')
    .regex(/^[a-zA-Z0-9_]+$/, '只能包含字母数字下划线'),

  // 邮箱、URL 等内置校验
  email: z.string().email('邮箱格式不正确'),
  website: z.string().url('网址格式不正确').optional(),

  // 数字:coerce 会把表单里的字符串 "18" 自动转成数字 18
  age: z.coerce.number().int('必须是整数').min(18, '需年满 18').max(120),

  // 枚举
  role: z.enum(['admin', 'editor', 'viewer'], {
    errorMap: () => ({ message: '角色不合法' })
  }),

  // 嵌套对象
  address: z.object({
    province: z.string().min(1, '请选择省份'),
    city: z.string().min(1, '请选择城市'),
    detail: z.string().min(5, '详细地址至少 5 字')
  }),

  // 数组 + 元素约束
  tags: z.array(z.string()).min(1, '至少一个标签').max(5, '最多 5 个标签'),

  // 可选 + 默认值
  newsletter: z.boolean().default(false)
});

// 关键:从 schema 推导类型,schema 一改类型自动同步
type User = z.infer<typeof userSchema>;

跨字段校验用 refine / superRefine。 单字段校验用链式方法,涉及多字段关系(如两次密码一致、结束日期晚于开始日期)用 refine 或更灵活的 superRefine。

typescriptCode
const passwordSchema = z.object({
  password: z.string().min(8, '密码至少 8 位'),
  confirmPassword: z.string(),
  startDate: z.string(),
  endDate: z.string()
})
  // refine:单条跨字段规则,失败信息挂到指定 path
  .refine((data) => data.password === data.confirmPassword, {
    message: '两次密码不一致',
    path: ['confirmPassword']
  })
  // superRefine:可添加多条错误,逻辑更复杂时用
  .superRefine((data, ctx) => {
    if (new Date(data.endDate) <= new Date(data.startDate)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: '结束日期必须晚于开始日期',
        path: ['endDate']
      });
    }
  });

输入输出类型不一致时用 z.input / z.output。 当 schema 里用了 transform 或 coerce,输入类型和输出类型会不同,此时用 z.input 给表单,用 z.output 给提交后的数据。

typescriptCode
const s = z.object({
  price: z.string().transform((v) => parseFloat(v)) // 输入 string,输出 number
});
type FormInput = z.input<typeof s>;   // { price: string }
type Parsed = z.output<typeof s>;      // { price: number }

RHF + Zod 集成完整实战

把 RHF 和 Zod 用 zodResolver 粘合,就得到了"端到端类型安全 + 声明式校验 + 高性能"的黄金组合。下面是一个包含嵌套对象、数组、跨字段校验的完整例子。

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

const orderSchema = z.object({
  customer: z.object({
    name: z.string().min(2, '姓名至少 2 字'),
    phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确')
  }),
  items: z.array(z.object({
    product: z.string().min(1, '请选择商品'),
    quantity: z.coerce.number().int().min(1, '数量至少 1')
  })).min(1, '至少一件商品'),
  couponCode: z.string().optional(),
  agreeTerms: z.literal(true, {
    errorMap: () => ({ message: '需同意服务条款' })
  })
});

type OrderForm = z.infer<typeof orderSchema>;

function OrderCheckout() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isValid }
  } = useForm<OrderForm>({
    resolver: zodResolver(orderSchema),
    mode: 'onBlur',
    defaultValues: {
      customer: { name: '', phone: '' },
      items: [{ product: '', quantity: 1 }],
      agreeTerms: false as unknown as true
    }
  });

  const onSubmit = async (data: OrderForm) => {
    // data 已通过 Zod 校验且带完整类型,可直接发给后端
    await fetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('customer.name')} placeholder="姓名" />
      {errors.customer?.name && <span>{errors.customer.name.message}</span>}

      <input {...register('customer.phone')} placeholder="手机号" />
      {errors.customer?.phone && <span>{errors.customer.phone.message}</span>}

      <input {...register('items.0.product')} placeholder="商品" />
      <input type="number" {...register('items.0.quantity')} />

      <label>
        <input type="checkbox" {...register('agreeTerms')} />
        我已阅读并同意服务条款
      </label>
      {errors.agreeTerms && <span>{errors.agreeTerms.message}</span>}

      <button type="submit" disabled={isSubmitting || !isValid}>
        {isSubmitting ? '提交中...' : '提交订单'}
      </button>
    </form>
  );
}

Formik 对比与迁移

Formik 是 RHF 之前的老牌表单库(约 45KB gzip,明显大于 RHF),采用集中式受控状态管理。它 API 直观、生态成熟,但在大型表单下每次输入都会触发整棵表单树重渲染,性能不及 RHF。

tsxCode
// Formik 基础写法:受控、集中管理
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';

const schema = Yup.object({
  email: Yup.string().email('邮箱格式不正确').required('必填'),
  password: Yup.string().min(8, '至少 8 位').required('必填')
});

function FormikLogin() {
  return (
    <Formik
      initialValues={{ email: '', password: '' }}
      validationSchema={schema}
      onSubmit={async (values, { setSubmitting }) => {
        await login(values);
        setSubmitting(false);
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <Field name="email" type="email" />
          <ErrorMessage name="email" component="span" />
          <Field name="password" type="password" />
          <ErrorMessage name="password" component="span" />
          <button type="submit" disabled={isSubmitting}>登录</button>
        </Form>
      )}
    </Formik>
  );
}

Formik vs RHF 关键差异:

| 维度 | Formik | React Hook Form |

| --- | --- | --- |

| 架构 | 受控、集中 state | 非受控、订阅 |

| 包体积 gzip | 约 45KB | 约 9KB |

| 20 字段输入一次的重渲染 | 整表单重渲染 | 仅当前字段 |

| 校验库 | 通常配 Yup | 通常配 Zod |

| 心智负担 | 低,直观 | 中,需理解订阅 |

| 大型表单性能 | 一般 | 优秀 |

| 维护活跃度 | 放缓 | 活跃 |

迁移要点: Formik 的 Field 对应 RHF 的 register;values 对象订阅改为 watch/useWatch;validationSchema(Yup)迁到 zodResolver(Zod);setFieldValue 对应 setValue;FieldArray 对应 useFieldArray。迁移收益主要在性能与体积——某中后台把 30 字段的 Formik 表单迁到 RHF 后,输入帧率从卡顿的约 40fps 回到稳定 60fps,首屏 JS 减少约 36KB。

复杂场景一:多步表单 / 向导

多步表单(wizard/stepper)把长表单拆成若干步,每步只校验并展示当前部分,能显著降低用户的填写压力——研究显示把 15 字段拆成 3 步后,完成率通常能提升 10% 以上。关键是"跨步保留状态"和"下一步前只校验当前步"。

tsxCode
// 三步注册向导:单个 useForm 贯穿全程,trigger 分步校验
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const wizardSchema = z.object({
  // 第一步
  username: z.string().min(3, '至少 3 字'),
  email: z.string().email('邮箱不正确'),
  // 第二步
  company: z.string().min(1, '请填写公司'),
  role: z.string().min(1, '请填写职位'),
  // 第三步
  plan: z.enum(['free', 'pro', 'team'])
});
type Wizard = z.infer<typeof wizardSchema>;

// 每一步管辖哪些字段
const stepFields: Record<number, (keyof Wizard)[]> = {
  0: ['username', 'email'],
  1: ['company', 'role'],
  2: ['plan']
};

function RegisterWizard() {
  const [step, setStep] = useState(0);
  const { register, handleSubmit, trigger, formState: { errors } } = useForm<Wizard>({
    resolver: zodResolver(wizardSchema),
    mode: 'onBlur'
  });

  const next = async () => {
    // 只校验当前步的字段,全通过才允许进入下一步
    const ok = await trigger(stepFields[step]);
    if (ok) setStep((s) => Math.min(s + 1, 2));
  };
  const prev = () => setStep((s) => Math.max(s - 1, 0));

  const onSubmit = (data: Wizard) => console.log('最终提交', data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 进度指示 */}
      <p>第 {step + 1} / 3 步</p>

      {step === 0 && (
        <>
          <input {...register('username')} placeholder="用户名" />
          {errors.username && <span>{errors.username.message}</span>}
          <input {...register('email')} placeholder="邮箱" />
          {errors.email && <span>{errors.email.message}</span>}
        </>
      )}
      {step === 1 && (
        <>
          <input {...register('company')} placeholder="公司" />
          {errors.company && <span>{errors.company.message}</span>}
          <input {...register('role')} placeholder="职位" />
          {errors.role && <span>{errors.role.message}</span>}
        </>
      )}
      {step === 2 && (
        <select {...register('plan')}>
          <option value="free">免费版</option>
          <option value="pro">专业版</option>
          <option value="team">团队版</option>
        </select>
      )}

      <div>
        {step > 0 && <button type="button" onClick={prev}>上一步</button>}
        {step < 2 && <button type="button" onClick={next}>下一步</button>}
        {step === 2 && <button type="submit">完成注册</button>}
      </div>
    </form>
  );
}

为什么用单个 useForm 而非每步一个? 单个 useForm 让所有字段的值天然跨步保留,切回上一步数据还在;若每步独立 useForm,则需要额外把每步的值提升到父级 state 手动合并,复杂且易错。

复杂场景二:文件上传含进度

文件上传是非受控的唯一必然场景(file input 无法受控)。要展示上传进度,fetch 目前对上传进度支持有限,实践中仍常用 XMLHttpRequest 的 upload.onprogress。

tsxCode
// 带进度条 + 大小/类型校验 + 可取消的文件上传
function FileUpload() {
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState('');
  const xhrRef = useRef<XMLHttpRequest | null>(null);

  const MAX_SIZE = 5 * 1024 * 1024; // 5MB
  const ACCEPT = ['image/png', 'image/jpeg', 'application/pdf'];

  const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // 前端预校验:类型 + 大小
    if (!ACCEPT.includes(file.type)) {
      setError('仅支持 PNG / JPG / PDF');
      return;
    }
    if (file.size > MAX_SIZE) {
      setError('文件不能超过 5MB');
      return;
    }
    setError('');

    const formData = new FormData();
    formData.append('file', file);

    const xhr = new XMLHttpRequest();
    xhrRef.current = xhr;
    xhr.open('POST', '/api/upload');

    // 关键:监听上传进度
    xhr.upload.onprogress = (evt) => {
      if (evt.lengthComputable) {
        setProgress(Math.round((evt.loaded / evt.total) * 100));
      }
    };
    xhr.onload = () => {
      if (xhr.status === 200) setProgress(100);
      else setError('上传失败');
    };
    xhr.onerror = () => setError('网络错误');
    xhr.send(formData);
  };

  const cancel = () => {
    xhrRef.current?.abort();
    setProgress(0);
  };

  return (
    <div>
      <input type="file" accept={ACCEPT.join(',')} onChange={handleFile} />
      {progress > 0 && progress < 100 && (
        <>
          <progress value={progress} max={100} /> {progress}%
          <button type="button" onClick={cancel}>取消</button>
        </>
      )}
      {progress === 100 && <span>上传完成</span>}
      {error && <span role="alert">{error}</span>}
    </div>
  );
}

复杂场景三:条件字段与跨字段联动

条件字段指"某字段是否显示/是否必填取决于另一个字段的值"。例如"是否开发票"选是时才显示发票抬头。用 watch 监听控制字段,条件渲染并配合动态校验。

tsxCode
// 条件字段:勾选开发票才显示并要求填写抬头
import { useForm } from 'react-hook-form';

function InvoiceOption() {
  const { register, watch, handleSubmit, formState: { errors } } = useForm({
    defaultValues: { needInvoice: false, invoiceTitle: '' }
  });

  const needInvoice = watch('needInvoice');

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <label>
        <input type="checkbox" {...register('needInvoice')} /> 需要发票
      </label>

      {needInvoice && (
        <div>
          <input
            {...register('invoiceTitle', {
              // 仅在需要发票时必填,用 validate 动态判断
              validate: (v) => !needInvoice || v.length > 0 || '请填写发票抬头'
            })}
            placeholder="发票抬头"
          />
          {errors.invoiceTitle && <span>{errors.invoiceTitle.message}</span>}
        </div>
      )}

      <button type="submit">提交</button>
    </form>
  );
}

复杂场景四:草稿自动保存

对长表单(如文章编辑器、申请表),"自动保存草稿"能极大降低意外丢失的风险。做法是 watch 全表单 + 防抖后写入 localStorage 或后端。

tsxCode
// 自动保存草稿:变更后防抖 1000ms 落盘,进入时恢复
import { useForm } from 'react-hook-form';

const DRAFT_KEY = 'article-draft';

function ArticleEditor() {
  const { register, watch, reset, handleSubmit } = useForm({
    defaultValues: { title: '', body: '' }
  });

  // 进入时恢复草稿
  useEffect(() => {
    const saved = localStorage.getItem(DRAFT_KEY);
    if (saved) reset(JSON.parse(saved));
  }, [reset]);

  // 监听全表单变化,防抖后保存
  useEffect(() => {
    const debounced = debounce((value: unknown) => {
      localStorage.setItem(DRAFT_KEY, JSON.stringify(value));
    }, 1000);

    const subscription = watch((value) => debounced(value));
    return () => {
      subscription.unsubscribe();
      debounced.cancel();
    };
  }, [watch]);

  const onSubmit = (data: unknown) => {
    localStorage.removeItem(DRAFT_KEY); // 正式提交后清草稿
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('title')} placeholder="标题" />
      <textarea {...register('body')} placeholder="正文" rows={10} />
      <button type="submit">发布</button>
    </form>
  );
}

草稿保存的注意点:防抖间隔不宜太短(推荐 800~1500ms,太短会频繁写盘);localStorage 有约 5MB 上限,超大草稿应写后端;恢复草稿时最好给用户"发现未完成草稿,是否恢复"的选择,而非静默覆盖。

复杂场景五:i18n 错误信息

多语言应用里,错误信息不能写死中文。Zod 支持全局 errorMap,也可在 schema 里传入翻译函数,让同一 schema 输出不同语言的错误。

tsxCode
// 用翻译函数生成 schema,切换语言即切换错误文案
import { z } from 'zod';

type TFunc = (key: string, params?: Record<string, unknown>) => string;

function makeSchema(t: TFunc) {
  return z.object({
    email: z.string().email(t('errors.email')),
    password: z.string().min(8, t('errors.passwordMin', { min: 8 }))
  });
}

// 组件里根据当前语言生成 schema
function LoginForm() {
  const { t, locale } = useI18n();
  const schema = useMemo(() => makeSchema(t), [t, locale]);
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema)
  });
  // locale 变化时 schema 重建,错误文案随之切换
  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}
      <button type="submit">{t('login.submit')}</button>
    </form>
  );
}

无障碍(a11y)最佳实践

表单是 a11y 的重灾区。做好无障碍不仅服务残障用户,也提升所有人的可用性和 SEO。核心要点:

label 与控件关联:用 htmlFor 关联,或把 input 包在 label 里。缺少 label 的输入框对屏幕阅读器完全不可用。
错误用 role="alert" 或 aria-live:错误出现时能被屏幕阅读器即时播报。
aria-invalid 与 aria-describedby:把错误消息的 id 关联到输入框,读屏时会连带读出错误原因。
必填标注 aria-required 或 required:而非仅靠视觉上的红色星号。
提交失败聚焦首个错误字段:让键盘/读屏用户能快速定位问题。
颜色不作为唯一信号:错误除了变红,还要有文字或图标(约 8% 男性有色弱)。
tsxCode
// 无障碍表单字段的完整写法
function A11yField({ register, name, label, error, required }: any) {
  const id = `field-${name}`;
  const errorId = `${id}-error`;
  return (
    <div>
      <label htmlFor={id}>
        {label}{required && <span aria-hidden="true"> *</span>}
      </label>
      <input
        id={id}
        {...register(name)}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      {error && (
        <span id={errorId} role="alert">
          {error.message}
        </span>
      )}
    </div>
  );
}

性能优化:字段级订阅与虚拟化

RHF 的性能优势来自"订阅",但用错了照样卡。几条关键法则:

1. 优先 useWatch 而非 watch。 顶层 watch 会让整个组件在被监听字段变化时重渲染,把 watch 下沉到小组件用 useWatch 可把重渲染范围缩到最小。

2. 用 Controller 隔离受控的第三方组件。 每个 Controller 内部维护自己的订阅,一个 Select 变化不会波及其它字段。

3. 长表单虚拟化。 当字段数达到数百(如批量编辑表格、动态问卷),一次性渲染所有 DOM 会拖慢首屏。用 react-window 只渲染可视区域的行。

tsxCode
// 虚拟化长表单:500 行只渲染可视的约 15 行
import { FixedSizeList } from 'react-window';
import { useFormContext } from 'react-hook-form';

function VirtualizedRows({ count }: { count: number }) {
  const { register } = useFormContext();
  return (
    <FixedSizeList height={400} itemCount={count} itemSize={48} width="100%">
      {({ index, style }) => (
        <div style={style}>
          <input {...register(`rows.${index}.value`)} placeholder={`第 ${index + 1} 行`} />
        </div>
      )}
    </FixedSizeList>
  );
}

500 字段的表单不做虚拟化,首次渲染可能耗时数百毫秒并产生上千个 DOM 节点;虚拟化后 DOM 节点常驻仅约 15 个,首屏渲染回到毫秒级。

4. 防抖高频校验。 异步校验(如用户名查重)务必防抖,300ms 是常用值;不防抖会在每次按键都打一次接口,既浪费又可能触发限流。

服务端校验兜底

再次强调:客户端校验只是体验优化,绝不能作为安全边界。攻击者可以绕过前端直接打接口。服务端必须独立完成完整校验,理想做法是"前后端共用同一份 Zod schema",既保证一致又避免重复维护。

typescriptCode
// shared/schema.ts —— 前后端共享
import { z } from 'zod';
export const signupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8)
});

// server route —— 服务端用同一 schema 校验
export async function POST(req: Request) {
  const body = await req.json();
  const parsed = signupSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { fieldErrors: parsed.error.flatten().fieldErrors },
      { status: 400 }
    );
  }
  // 唯一性等只有服务端能做的校验
  if (await emailExists(parsed.data.email)) {
    return Response.json(
      { fieldErrors: { email: ['该邮箱已注册'] } },
      { status: 409 }
    );
  }
  await createUser(parsed.data);
  return Response.json({ ok: true });
}

表单库选型对比表

| 库 | gzip 体积 | 架构 | 校验搭配 | TS 友好 | 适用规模 |

| --- | --- | --- | --- | --- | --- |

| 原生 FormData | 0KB | DOM | 手写 | 一般 | 极简表单 |

| React 19 Actions | 0KB(内置) | 非受控 + Action | Zod | 好 | 简单到中等、SSR |

| React Hook Form | 约 9KB | 非受控 + 订阅 | Zod | 极好 | 中大型、性能敏感 |

| Formik | 约 45KB | 受控集中 | Yup | 好 | 中小型、遗留项目 |

| TanStack Form | 约 12KB | 订阅、框架无关 | 内置/Zod | 极好 | 跨框架、类型极致 |

一句话选型:极简用 FormData 或 React 19 Actions;绝大多数项目用 React Hook Form + Zod;遗留 Formik 项目可保留但新表单建议 RHF;追求跨框架一致或极致类型可看 TanStack Form。

常见坑清单(补充)

defaultValues 用了会变的引用:每次渲染传新对象/数组会导致 RHF 反复重置。用稳定引用或 useMemo。
reset 在异步数据到达前调用:编辑表单先渲染空值再 reset,注意用 useEffect 依赖数据。
valueAsNumber 忘记加:number input 默认返回字符串,"18" !== 18 会让数值校验/计算出错。
watch 整个表单做联动:性能杀手,改用 useWatch 精确订阅。
Controller 的 field 没完整透传:漏掉 onBlur 会导致 touched/校验时机异常。
多步表单每步独立 useForm:跨步状态丢失,应共用一个 useForm。
异步校验不防抖:每次按键打接口,浪费且易被限流。
错误只靠颜色:色弱用户无法感知,需配文字/图标。
提交成功不 reset:isSubmitSuccessful 后应 reset,否则残留旧值。
file input 塞进受控 state:文件无法受控,必然报错,只能 ref。

测试表单(RTL + user-event)

表单测试用 React Testing Library + user-event,理念是"像用户一样操作"——查找可访问的元素(byLabelText、byRole),模拟真实输入,断言可见结果,而非测实现细节。

tsxCode
// 测试登录表单:校验错误 + 成功提交
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

test('空提交时展示校验错误', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={jest.fn()} />);

  await user.click(screen.getByRole('button', { name: /登录/ }));

  // 错误用 role="alert",可被 findByRole 捕获
  expect(await screen.findByText('邮箱不正确')).toBeInTheDocument();
  expect(screen.getByText('密码至少 8 位')).toBeInTheDocument();
});

test('填写正确并提交,回调收到数据', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  // 用 label 文本定位输入框,保证 a11y 同时测到
  await user.type(screen.getByLabelText('邮箱'), 'a@b.com');
  await user.type(screen.getByLabelText('密码'), 'password123');
  await user.click(screen.getByRole('button', { name: /登录/ }));

  expect(handleSubmit).toHaveBeenCalledWith(
    { email: 'a@b.com', password: 'password123' },
    expect.anything()
  );
});

test('异步用户名查重失败时报错', async () => {
  const user = userEvent.setup();
  // mock 接口返回不可用
  jest.spyOn(global, 'fetch').mockResolvedValue({
    json: async () => ({ available: false })
  } as Response);

  render(<SignupForm />);
  await user.type(screen.getByLabelText('用户名'), 'taken');
  await user.tab(); // 触发 blur

  expect(await screen.findByText('用户名已被占用')).toBeInTheDocument();
});

测试要点:优先用 byRole / byLabelText 而非 byTestId(顺带验证 a11y);异步错误用 findBy 而非 getBy(等待出现);避免测内部 state,只断言用户可见的结果;user-event 比 fireEvent 更接近真实交互(会触发 focus、blur、键盘序列)。

总结

React 表单的核心矛盾是"实时反馈"与"渲染性能"的平衡。受控组件给你最强的控制力但代价是渲染成本,非受控组件性能好但控制弱,React Hook Form 通过非受控 + 订阅取得了两者的最佳平衡,再配合 Zod 实现一份 schema 打通校验与类型。把校验时机、防重复提交、错误聚焦、服务端兜底这些细节做到位,表单体验和业务转化都会明显受益。

| 主题 | 推荐做法 | 反模式 |

| --- | --- | --- |

| 方案选型 | 中大型用 RHF + Zod | 大表单硬用全受控 |

| 校验时机 | 首次 onBlur、出错后 onChange | 一上来 onChange 满屏红 |

| 安全 | 前端体验校验 + 服务端强制校验 | 只信任前端校验 |

| 性能 | 字段级订阅、Controller 隔离、防抖 | 每次输入全表单渲染 |

| 提交 | isSubmitting 禁用、失败聚焦 | 按钮不禁用、无反馈 |

| 类型 | z.infer 从 schema 推导类型 | 手写重复的类型定义 |