React 表单与复杂交互:受控组件、校验与异步提交

简单表单随手写写就能跑,真正麻烦的是「字段一多」「交互一复杂」,状态、校验、提示、提交流程容易搅在一起。

受控组件是 React 表单的基本单元:值从 props 来,变化通过回调告诉外面。

 1type TextFieldProps = {
 2  label: string;
 3  value: string;
 4  onChange: (next: string) => void;
 5};
 6
 7function TextField({ label, value, onChange }: TextFieldProps) {
 8  return (
 9    <label>
10      <span>{label}</span>
11      <input
12        value={value}
13        onChange={e => onChange(e.target.value)}
14      />
15    </label>
16  );
17}

在表单里组合:

 1function ProfileForm() {
 2  const [name, setName] = useState('');
 3  const [email, setEmail] = useState('');
 4
 5  function handleSubmit(e: React.FormEvent) {
 6    e.preventDefault();
 7    // 提交 name / email
 8  }
 9
10  return (
11    <form onSubmit={handleSubmit}>
12      <TextField label="姓名" value={name} onChange={setName} />
13      <TextField label="邮箱" value={email} onChange={setEmail} />
14      <button type="submit">保存</button>
15    </form>
16  );
17}

先把输入这件事变得可预测,是处理后面各种校验和交互的前提。

在复杂表单里,单靠几个 useState 很快会乱掉。一般会需要几类信息:

  • values:当前各字段的值;
  • errors:每个字段当前的校验错误(如果有);
  • touched:用户是否已经操作过该字段;
  • dirty:字段是否被修改过。

可以用一个 Hook 统一管理:

 1type Values = {
 2  name: string;
 3  email: string;
 4};
 5
 6type Errors = Partial<Record<keyof Values, string>>;
 7
 8function useForm(initialValues: Values) {
 9  const [values, setValues] = useState<Values>(initialValues);
10  const [errors, setErrors] = useState<Errors>({});
11  const [touched, setTouched] = useState<Partial<Record<keyof Values, boolean>>>({});
12
13  function setField<K extends keyof Values>(key: K, value: Values[K]) {
14    setValues(prev => ({ ...prev, [key]: value }));
15  }
16
17  function setFieldTouched<K extends keyof Values>(key: K) {
18    setTouched(prev => ({ ...prev, [key]: true }));
19  }
20
21  return {
22    values,
23    errors,
24    touched,
25    setField,
26    setFieldTouched,
27    setErrors,
28  };
29}

表单组件里就可以专注于「在什么时机触发校验」「错误怎么展示」。

常见做法是写一个校验函数:

 1function validate(values: Values): Errors {
 2  const errors: Errors = {};
 3
 4  if (!values.name.trim()) {
 5    errors.name = '姓名不能为空';
 6  }
 7  if (!values.email.includes('@')) {
 8    errors.email = '邮箱格式不正确';
 9  }
10
11  return errors;
12}

常用的触发时机:

  • onBlur:用户离开字段时校验;
  • onChange:每次输入都校验(对性能要求高一些);
  • onSubmit:提交前整体校验一次。

一种比较折中的方式是:

  • 失焦或提交前校验;
  • 对有错误的字段,在后续输入时做「轻量实时校验」。

例如用户名是否被占用,需要请求后端。
这类校验要防止「旧请求结果覆盖新状态」,常见做法之一是带上一个版本号或使用 abort:

1let currentRequest = 0;
2
3async function validateUsername(username: string) {
4  const requestId = ++currentRequest;
5  const res = await fetch(`/api/check-username?u=${username}`);
6  if (requestId !== currentRequest) return; // 丢弃过期响应
7  const data = await res.json();
8  return data.available;
9}

或者更实际一点,交给 React Query / SWR 一类库,用它们自带的去抖和缓存机制。

提交表单时,通常至少要处理:

  • 禁用按钮避免重复提交;
  • 显示 loading 状态;
  • 错误提示和重试;
  • 成功后的跳转或提示。
 1function ProfileForm() {
 2  const { values, errors, setField, setFieldTouched, setErrors } = useForm({
 3    name: '',
 4    email: '',
 5  });
 6  const [submitting, setSubmitting] = useState(false);
 7  const [submitError, setSubmitError] = useState<string | null>(null);
 8
 9  async function handleSubmit(e: React.FormEvent) {
10    e.preventDefault();
11    const nextErrors = validate(values);
12    if (Object.keys(nextErrors).length > 0) {
13      setErrors(nextErrors);
14      return;
15    }
16
17    setSubmitting(true);
18    setSubmitError(null);
19    try {
20      await saveProfile(values);
21      // 可以在这里做跳转或全局提示
22    } catch (e) {
23      setSubmitError((e as Error).message);
24    } finally {
25      setSubmitting(false);
26    }
27  }
28
29  // 渲染略
30}

这里的重点是把「校验不过」和「网络/服务器错误」清楚地区分开,用户能看得懂现在卡在了哪一步。

当表单简单时,自己用几个 Hook 管住就够了;当出现这些信号时,可以考虑引入表单库(如 React Hook Form、Formik 等):

  • 表单字段很多,还包含数组/嵌套对象;
  • 校验规则复杂,涉及多字段联动;
  • 大量业务都要复用同一套「值/错误/touched/dirty」逻辑;
  • 对性能有要求,希望减少重渲染。

这些库的内核,基本都围绕前面提到的那几个维度展开,只是帮你把样板代码和性能细节都包了一层。

  • 受控组件让表单输入变得可预测,是处理后续复杂交互的基础。
  • 把表单状态拆成值、错误、touched、dirty 等几个维度,会比简单地堆 useState 好维护得多。
  • 校验既要考虑规则本身,也要设计好触发时机和错误展示方式。
  • 异步提交阶段要清楚地区分「校验错误」和「网络/服务器错误」,同时给出合适的 loading 和重试体验。