React 基础体系 · 第 36/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Hook Form:字段注册、Schema、动态表单和性能

React Hook Form(RHF)是 React 生态中的表单状态管理库。它的核心目标不是替代 React,而是把表单字段的值、校验状态、提交状态和错误状态组织起来,同时尽量避免“每输入一个字符就让整个表单组件重新渲染”。

标题中的四个概念分别对应四个层次:

  • 字段注册:RHF 如何知道某个输入框属于表单,以及如何读取和更新它的值。
  • Schema:如何用一个可组合的规则描述数据结构,并将其接入 RHF。
  • 动态表单:字段数量、字段路径或字段类型运行时变化时,如何保证数据和 React 列表身份一致。
  • 性能:哪些状态变化会触发哪些组件重新渲染,以及如何定位真正的性能问题。

一、先建立表单的状态模型

一个表单至少包含三类数据:

  1. 字段值:用户当前输入的原始值。
  2. 校验状态:字段是否有效、错误消息是什么、是否已经被触碰。
  3. 提交状态:是否正在提交、是否提交成功、服务端是否返回了错误。

可以把一次表单提交抽象为:

raw input客户端校验validated input服务端校验persisted result\text{raw input} \overset{\text{客户端校验}}{\longrightarrow} \text{validated input} \overset{\text{服务端校验}}{\longrightarrow} \text{persisted result}

这里的 raw input 是浏览器输入产生的值,validated input 是通过 Schema 和业务规则后的数据,persisted result 才是服务端真正接受并写入数据库的数据。

客户端校验只能改善交互,不能替代服务端校验。原因是浏览器中的 JavaScript、请求内容和校验逻辑都可以被用户修改。

1. 字段值不等于 React state

在传统的受控组件中,数据流通常是:

const [email, setEmail] = useState("");

<input
  value={email}
  onChange={(event) => setEmail(event.target.value)}
/>

每次输入的过程是:

  1. 浏览器产生 input 事件。
  2. onChange 调用 setEmail
  3. 组件重新渲染。
  4. React 将新的 value 写回输入框。

RHF 常见的用法则是:

const { register } = useForm();

<input {...register("email")} />

此时输入框通常仍由 DOM 保持当前值,RHF 通过注册时附加的事件处理器和 ref 观察字段,而不是要求父组件保存每一次击键产生的值。

这就是 RHF 常被称为偏向 非受控表单 的原因。但“非受控”并不意味着没有状态。RHF 仍然维护字段值、错误、脏状态和提交状态,只是这些状态不必全部通过 React 组件的 useState 在每次输入时传播。


二、字段注册:register 到底做了什么

1. register 的返回值

下面的代码:

<input {...register("email")} />

可以概念性地理解为:

const fieldProps = register("email");

<input
  name={fieldProps.name}
  onChange={fieldProps.onChange}
  onBlur={fieldProps.onBlur}
  ref={fieldProps.ref}
/>

register("email") 返回的对象通常包含:

  • name:字段路径;
  • onChange:通知 RHF 字段发生变化;
  • onBlur:通知 RHF 字段失去焦点;
  • ref:让 RHF 获取 DOM 节点、读取初始值或执行聚焦;
  • 其他由当前版本和配置决定的字段属性。

因此,下面这种写法通常是错误的:

<input {...register("email")} ref={someOtherRef} />

后面的 ref 会覆盖 register 提供的 ref,导致 RHF 可能无法正确追踪该字段。若必须合并 ref,需要显式合并两个 ref。

2. 字段名是数据路径

字段名不是仅用于 HTML 的字符串,它同时决定了提交数据的结构。

<input {...register("user.name")} />
<input {...register("user.email")} />

提交结果可以是:

{
  user: {
    name: "Ada",
    email: "ada@example.com"
  }
}

数组路径也可以直接表达嵌套数据:

<input {...register("items.0.sku")} />
<input {...register("items.1.sku")} />

对应的数据形状类似:

{
  items: [
    { sku: "A-001" },
    { sku: "B-002" }
  ]
}

字段路径必须与类型和 Schema 的结构一致。比如 Schema 期望 items[].sku,却注册成 products[].code,客户端校验即使本身正确,也不会命中预期字段。

3. defaultValues 是初始快照

推荐从 useForm 提供初始值:

type ProfileForm = {
  name: string;
  age: number;
};

const form = useForm<ProfileForm>({
  defaultValues: {
    name: "",
    age: 0,
  },
});

defaultValues 的作用包括:

  • 初始化未填写字段;
  • 参与 isDirtydirtyFields 的比较;
  • 避免输入框从 undefined 突然变成字符串;
  • 为重置操作提供基准值。

一个常见误区是以为 defaultValues 会随着 React props 自动更新:

function ProfileEditor({ profile }: { profile: Profile }) {
  const form = useForm({
    defaultValues: profile,
  });

  // profile 后续发生变化时,defaultValues 不会自动作为新值应用
}

如果数据是异步加载的,应在加载完成后显式重置:

const form = useForm<ProfileForm>({
  defaultValues: {
    name: "",
    age: 0,
  },
});

useEffect(() => {
  if (profile) {
    form.reset(profile);
  }
}, [profile, form]);

也可以使用 RHF 支持的异步初始值能力,但实际项目仍应明确数据加载完成后的重置语义,尤其是要决定:加载新数据时是否覆盖用户已经修改过的字段。

4. HTML 输入值与 TypeScript 类型不自动一致

原生 <input> 的值通常先以字符串形式产生:

<input type="number" {...register("age")} />

如果没有额外配置,age 可能仍然以字符串 "18" 进入表单数据,而不是数字 18

可以使用转换选项:

<input
  type="number"
  {...register("age", {
    valueAsNumber: true,
    min: {
      value: 0,
      message: "年龄不能小于 0",
    },
  })}
/>

但要注意,空的 number input 可能被转换为 NaN。Schema 和服务端必须明确如何处理空值,而不能仅根据 TypeScript 的 number 判断运行时一定是有效数字。

对日期也类似:

<input
  type="date"
  {...register("birthday", {
    valueAsDate: true,
  })}
/>

此时得到的可能是 Date 或无效日期值,服务端序列化前仍需检查。


三、字段级规则与 Schema 校验

RHF 自带字段级校验规则:

<input
  {...register("username", {
    required: "用户名不能为空",
    minLength: {
      value: 3,
      message: "用户名至少 3 个字符",
    },
    validate: async (value) => {
      const available = await checkUsername(value);
      return available || "用户名已经被占用";
    },
  })}
/>

这些规则适合简单字段校验。但当表单有嵌套对象、数组、字段间关系或需要复用同一套规则时,单独把规则散落在 JSX 中会变得难以维护。

1. Schema 的定义

Schema 是对数据形状和约束的声明。例如使用 Zod:

import { z } from "zod";

export const profileSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, "姓名不能为空")
    .max(50, "姓名不能超过 50 个字符"),

  email: z
    .string()
    .trim()
    .email("邮箱格式不正确"),

  age: z
    .number()
    .int("年龄必须是整数")
    .min(18, "年龄必须年满 18 岁"),
});

export type ProfileInput = z.infer<typeof profileSchema>;

这里的 Schema 同时描述:

  • 顶层数据必须是对象;
  • name 必须是字符串,去除首尾空格后不能为空;
  • email 必须符合邮箱格式;
  • age 必须是整数且不小于 18。

z.infer<typeof profileSchema> 从 Schema 推导 TypeScript 类型,减少了“类型声明一份、校验规则再写一份”造成的不一致。

不过,TypeScript 类型只在编译阶段提供帮助,Schema 才是在运行时真正检查数据。

2. 将 Schema 接入 RHF

@hookform/resolvers 和 Zod 为例:

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

const form = useForm<ProfileInput>({
  resolver: zodResolver(profileSchema),
  defaultValues: {
    name: "",
    email: "",
    age: 18,
  },
  mode: "onBlur",
});

resolver 是 RHF 与外部校验库之间的适配器。提交或触发校验时,流程大致是:

  1. RHF 收集当前字段值;
  2. resolver 调用 Schema;
  3. Schema 返回成功数据或错误集合;
  4. resolver 将错误转换成 RHF 的 formState.errors
  5. RHF 根据错误路径渲染错误消息。

mode: "onBlur" 表示字段失去焦点后开始校验。常见模式包括:

  • onSubmit:提交时校验,交互更安静;
  • onBlur:离开字段时校验;
  • onChange:每次变更都校验,反馈及时但可能产生更多计算和渲染;
  • all:同时响应提交、失焦和变更等事件。

模式不是单纯的性能开关,它还决定用户何时看到错误。长文本或复杂 Schema 使用 onChange 时,可能在用户尚未完成输入前显示中间态错误。

3. 完整的字段级错误渲染

function ProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ProfileInput>({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      name: "",
      email: "",
      age: 18,
    },
    mode: "onBlur",
  });

  const onSubmit = async (data: ProfileInput) => {
    const response = await fetch("/api/profile", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      throw new Error("保存失败");
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">姓名</label>
        <input
          id="name"
          {...register("name")}
          aria-invalid={errors.name ? "true" : "false"}
          aria-describedby={errors.name ? "name-error" : undefined}
        />
        {errors.name && (
          <p id="name-error" role="alert">
            {errors.name.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">邮箱</label>
        <input
          id="email"
          type="email"
          {...register("email")}
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          <p id="email-error" role="alert">
            {errors.email.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="age">年龄</label>
        <input
          id="age"
          type="number"
          {...register("age", { valueAsNumber: true })}
          aria-invalid={errors.age ? "true" : "false"}
          aria-describedby={errors.age ? "age-error" : undefined}
        />
        {errors.age && (
          <p id="age-error" role="alert">
            {errors.age.message}
          </p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "保存中…" : "保存"}
      </button>
    </form>
  );
}

handleSubmit(onSubmit) 不只是一个事件包装器。它会先运行校验:

  • 校验失败:不调用 onSubmit,错误写入 formState.errors
  • 校验成功:将解析后的数据传给 onSubmit
  • isSubmitting 在异步提交期间用于阻止重复提交或显示状态。

需要捕获网络层异常:

const onSubmit = async (data: ProfileInput) => {
  try {
    const response = await fetch("/api/profile", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
  } catch (error) {
    // 显示全局错误、上报日志或更新服务端错误状态
  }
};

RHF 能处理客户端校验状态,但不会自动把所有网络异常变成用户可见的页面消息。


四、跨字段校验:Schema 不是只有单字段规则

很多约束无法通过单个字段独立判断,例如:

  • 密码和确认密码必须相同;
  • 开始日期不能晚于结束日期;
  • type"company" 时,taxId 必填。

以密码确认作为例子:

const registrationSchema = z
  .object({
    password: z.string().min(8, "密码至少 8 位"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ["confirmPassword"],
    message: "两次输入的密码不一致",
  });

这里的关键是 path: ["confirmPassword"]。跨字段条件是在对象层面判断的,但错误被定位到具体字段,因此 RHF 可以在确认密码输入框旁边显示消息。

如果省略路径,错误可能成为对象级错误,UI 需要自己处理:

const {
  formState: { errors },
} = useForm<RegistrationInput>({
  resolver: zodResolver(registrationSchema),
});

跨字段校验的因果链是:

  1. 单个字段值变化;
  2. Schema 需要读取同一对象中的其他字段;
  3. resolver 重新校验相关数据;
  4. 错误路径决定错误最终挂在哪个字段或表单根节点;
  5. UI 根据路径渲染消息。

因此,不能把所有跨字段规则都写成单字段的 validate,否则规则可能无法正确获取同级字段,也容易形成重复逻辑。


五、客户端 Schema 与服务端 Schema 的边界

在现代 React 应用中,组件可能运行在:

  • 浏览器客户端组件;
  • 服务端渲染环境;
  • 服务端路由或 API handler;
  • React Server Components 与客户端组件的组合边界。

RHF 主要用于浏览器交互,因此调用 useForm 的组件通常应位于客户端边界。以支持客户端指令的框架为例,使用浏览器事件和 Hook 的组件需要放在相应的客户端文件或客户端入口中。

但服务端仍必须重新验证请求:

// 服务端 handler 的概念性示例
export async function POST(request: Request) {
  const body: unknown = await request.json();

  const result = profileSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      {
        message: "输入数据无效",
        issues: result.error.issues,
      },
      { status: 422 },
    );
  }

  // result.data 是通过运行时 Schema 校验后的数据
  // 之后还要进行权限检查、唯一性检查和数据库操作
  return Response.json({ ok: true });
}

这里不能直接信任客户端传来的 JSON,也不能因为客户端已经运行过相同 Schema 就跳过服务端校验。

更准确的边界是:

客户端:
用户体验、即时反馈、减少无效请求

服务端:
安全边界、权限、业务真实性、并发一致性、最终数据校验

同一份 Schema 可以在客户端和服务端共享,但并不意味着所有规则都应共享。例如“用户名是否已占用”依赖数据库,不能只写成纯本地 Schema 规则。


六、将服务端字段错误映射回 RHF

服务端可能返回唯一性冲突或业务校验错误:

{
  "message": "保存失败",
  "fieldErrors": {
    "email": "该邮箱已注册"
  }
}

客户端可使用 setError

const {
  setError,
  setFocus,
  handleSubmit,
} = useForm<ProfileInput>({
  resolver: zodResolver(profileSchema),
});

const onSubmit = async (data: ProfileInput) => {
  const response = await fetch("/api/profile", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });

  if (response.ok) {
    return;
  }

  if (response.status === 422) {
    const body: {
      fieldErrors?: Partial<Record<keyof ProfileInput, string>>;
    } = await response.json();

    for (const [field, message] of Object.entries(
      body.fieldErrors ?? {},
    )) {
      if (message) {
        setError(field as keyof ProfileInput, {
          type: "server",
          message,
        });
      }
    }

    setFocus("email");
    return;
  }

  throw new Error("服务器暂时不可用");
};

如果错误属于整个表单,而不是某一个字段,可以使用根错误:

setError("root.server", {
  type: "server",
  message: "保存失败,请稍后重试",
});

然后渲染:

{errors.root?.server && (
  <p role="alert">{errors.root.server.message}</p>
)}

服务端错误映射的关键是保证路径协议一致。前端注册的是 items.0.sku,服务端却返回 items[0].sku,就需要在边界层统一路径格式,否则错误无法准确显示。


七、动态表单:为什么需要 useFieldArray

动态表单是指字段数量或字段结构运行时变化,例如:

  • 订单中的商品行可以添加和删除;
  • 联系人列表可以重新排序;
  • 问卷题目由服务端配置;
  • 根据选择的类型显示不同字段。

简单地用 useState 保存数组也能完成 UI,但如果同时让 RHF 管理每个字段,必须解决两个问题:

  1. 数组项的字段路径如何变化;
  2. React 列表中的组件身份如何保持稳定。

useFieldArray 专门处理这两个问题。

1. 一个完整的订单明细示例

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

const orderSchema = z.object({
  customer: z.string().min(1, "客户名称不能为空"),
  items: z
    .array(
      z.object({
        sku: z.string().min(1, "SKU 不能为空"),
        quantity: z
          .number()
          .int("数量必须是整数")
          .min(1, "数量至少为 1"),
      }),
    )
    .min(1, "至少添加一件商品"),
});

type OrderInput = z.infer<typeof orderSchema>;

export function OrderForm() {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<OrderInput>({
    resolver: zodResolver(orderSchema),
    defaultValues: {
      customer: "",
      items: [
        {
          sku: "",
          quantity: 1,
        },
      ],
    },
  });

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

  const onSubmit = async (data: OrderInput) => {
    const response = await fetch("/api/orders", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      throw new Error("订单提交失败");
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="customer">客户名称</label>
      <input id="customer" {...register("customer")} />
      {errors.customer && (
        <p role="alert">{errors.customer.message}</p>
      )}

      <h2>商品</h2>

      {fields.map((field, index) => (
        <div key={field.id}>
          <label htmlFor={`items-${index}-sku`}>SKU</label>
          <input
            id={`items-${index}-sku`}
            {...register(`items.${index}.sku`)}
          />
          {errors.items?.[index]?.sku && (
            <p role="alert">
              {errors.items[index]?.sku?.message}
            </p>
          )}

          <label htmlFor={`items-${index}-quantity`}>数量</label>
          <input
            id={`items-${index}-quantity`}
            type="number"
            {...register(`items.${index}.quantity`, {
              valueAsNumber: true,
            })}
          />
          {errors.items?.[index]?.quantity && (
            <p role="alert">
              {errors.items[index]?.quantity?.message}
            </p>
          )}

          <button
            type="button"
            onClick={() => remove(index)}
          >
            删除
          </button>

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

      {errors.items?.root && (
        <p role="alert">{errors.items.root.message}</p>
      )}

      <button
        type="button"
        onClick={() => append({ sku: "", quantity: 1 })}
      >
        添加商品
      </button>

      <button type="submit" disabled={isSubmitting}>
        提交订单
      </button>
    </form>
  );
}

这个例子中的数据流是:

  1. fields 描述当前数组行以及 RHF 为每行生成的稳定标识;
  2. fields.map 负责渲染行;
  3. register(\items.${index}.sku`)` 将输入映射到数组路径;
  4. append 增加完整的一行;
  5. remove 删除一行并更新索引路径;
  6. move 调整数组顺序;
  7. Schema 对整个 items 数组进行逐项和整体校验。

2. 为什么 key 必须使用 field.id

错误写法:

{fields.map((field, index) => (
  <div key={index}>
    {/* fields */}
  </div>
))}

当删除中间项时,React 会把后面的组件复用到前一个位置。例如原始行是:

A(index 0), B(index 1), C(index 2)

删除 A 后,数组变成:

B(index 0), C(index 1)

使用 key={index} 时,React 可能认为原 index 0 的组件仍然是“同一个组件”,只是数据换了。这在输入框、焦点、动画和本地组件状态上容易产生错位。

field.id 表示数组项的稳定身份:

{fields.map((field, index) => (
  <div key={field.id}>
    ...
  </div>
))}

索引仍然用于字段路径,因为当前数据路径是 items.0.skuitems.1.sku;但 React key 使用的是项身份。两者解决的是不同问题。

3. appendremove 与完整对象

数组操作应尽量传入完整对象:

append({
  sku: "",
  quantity: 1,
});

不要依赖一个结构不完整的对象:

append({ sku: "" } as OrderInput["items"][number]);

后者绕过了 TypeScript 的保护,运行时可能产生 undefined,使 Schema、默认值和 UI 三者不一致。

4. 更新单行时的行为

如果使用 update(index, value),某些情况下整行组件会被卸载并重新挂载。若只需要更新一个字段,可以使用:

setValue(`items.${index}.quantity`, 10, {
  shouldDirty: true,
  shouldValidate: true,
});

选择原则是:

  • 整行结构发生替换:使用 update
  • 单个字段值变化:使用 setValue
  • 数组整体替换:使用当前 RHF 版本提供的数组操作,确认是否会导致整组重建;
  • 需要保留局部组件状态或焦点:避免不必要的卸载重挂载。

5. shouldUnregister 的取舍

条件渲染会带来一个问题:字段从 DOM 中移除后,值是否还应保留?

const form = useForm({
  shouldUnregister: false,
});

常见语义是:

  • 保留注册过但暂时未显示的字段值;
  • 适合步骤表单或条件区域切换;
  • 但提交时可能包含当前界面不可见的旧值。

若设置为 true,字段卸载时会从表单状态移除:

  • 更接近原生表单字段存在即提交的直觉;
  • 条件字段不会残留旧数据;
  • 但步骤切换后可能需要重新恢复用户输入。

这不是纯技术优劣,而是数据语义选择:隐藏字段是“暂时不可见但仍属于草稿”,还是“已经不属于当前提交模型”。


八、动态字段类型与 Controller

并不是所有组件都能直接使用 register。原生 <input><select><textarea> 通常可以直接注册;但第三方选择器、日期选择器、富文本编辑器常常不是标准的 value/onChange 接口。

这时使用 Controller

import { Controller, useForm } from "react-hook-form";

type FormValues = {
  country: string;
};

function CountrySelect() {
  const { control } = useForm<FormValues>({
    defaultValues: {
      country: "",
    },
  });

  return (
    <Controller
      name="country"
      control={control}
      render={({ field, fieldState }) => (
        <div>
          <CustomSelect
            value={field.value}
            onChange={field.onChange}
            onBlur={field.onBlur}
            inputRef={field.ref}
          />

          {fieldState.error && (
            <p role="alert">{fieldState.error.message}</p>
          )}
        </div>
      )}
    />
  );
}

Controller 的作用是把受控组件的接口适配为 RHF 字段接口:

第三方组件的 value
        ↓
Controller field.value

第三方组件的 onChange
        ↓
Controller field.onChange

不要对同一个字段同时使用 registerController

// 错误倾向:同一字段被两套注册逻辑管理
<Controller name="country" control={control} ... />
<input {...register("country")} />

这会造成重复注册、值来源不明确或事件冲突。

Controller 并不代表整个表单必须变成受控模式。它只让特定字段使用受控适配,原生字段仍可以继续使用 register


九、性能:RHF 为什么通常能减少渲染

性能问题要先明确“谁在重新渲染”。一个组件重新渲染不等于浏览器一定完成了昂贵的 DOM 更新;反过来,少量 React 渲染也可能触发昂贵的计算或第三方组件更新。

RHF 的性能思路可以抽象为:

组件更新范围组件订阅的状态范围\text{组件更新范围} \approx \text{组件订阅的状态范围}

如果一个组件订阅整个表单,就可能因任意字段变化而重新渲染;如果它只订阅 email 的错误,就可以把更新范围限制在该字段。

1. watch 的订阅范围

const value = watch();

订阅整个表单,任何字段变化都可能让当前组件重新渲染。

const email = watch("email");

只读取一个字段,范围更窄。

但在大型表单中,更推荐使用 useWatch 将订阅隔离到专门的子组件:

import { useWatch, Control } from "react-hook-form";

function OrderSummary({
  control,
}: {
  control: Control<OrderInput>;
}) {
  const items = useWatch({
    control,
    name: "items",
  });

  const totalRows = items?.length ?? 0;

  return <p>当前有 {totalRows} 件商品</p>;
}

这样,汇总组件明确订阅 items,而不是让整个表单父组件读取所有字段。

2. useFormState 的局部订阅

import { useFormState, Control } from "react-hook-form";

function SubmitStatus({
  control,
}: {
  control: Control<OrderInput>;
}) {
  const { isSubmitting, isDirty } = useFormState({ control });

  return (
    <button type="submit" disabled={isSubmitting || !isDirty}>
      {isSubmitting ? "提交中…" : "提交"}
    </button>
  );
}

useFormState 让组件声明自己关心哪些表单状态。把错误、提交状态和字段渲染拆分到子组件后,输入一个字段通常不需要让整棵表单树都更新。

3. 精确读取错误

在子组件中:

function EmailField({
  register,
  error,
}: {
  register: ReturnType<typeof useForm<ProfileInput>>["register"];
  error?: { message?: string };
}) {
  return (
    <div>
      <input {...register("email")} />
      {error?.message && <p role="alert">{error.message}</p>}
    </div>
  );
}

父组件可以只把 errors.email 传下来,而不是把整个 formState 传给所有字段组件。

需要注意,RHF 的 formState 具有代理和订阅语义。常见写法是直接在组件渲染期间解构需要的属性:

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

不要把整个 formState 当作无差别的全局状态传遍组件树。

4. 受控组件的成本

受控组件的每次输入都需要:

  1. 触发 React 状态更新;
  2. 重新执行相关组件函数;
  3. 重新协调子树;
  4. 可能触发第三方组件内部逻辑。

这不意味着受控组件一定慢,也不意味着 Controller 一定有问题。性能取决于:

  • 字段数量;
  • 单字段渲染成本;
  • 是否有复杂计算;
  • 是否订阅整个表单;
  • 第三方组件是否本身昂贵;
  • 是否在输入事件中执行网络请求或大型 Schema 计算。

因此,register 通常是原生字段的低开销路径,Controller 是必要时的适配路径;不能把它们简单理解为“一个正确、一个错误”。


十、性能诊断应从测量开始

当表单感觉卡顿时,先确认问题属于哪一类:

1. React 重新渲染过多

使用 React DevTools Profiler 观察:

  • 输入单个字符时,哪些组件重新渲染;
  • 是否整个表单都被重新渲染;
  • 哪个组件提交了最长的渲染时间;
  • 是否因为 watch() 读取了整个表单。

典型问题:

function Form() {
  const allValues = watch();

  return (
    <>
      <ExpensivePreview values={allValues} />
      {/* 大量字段 */}
    </>
  );
}

改为拆分订阅:

function Preview({
  control,
}: {
  control: Control<FormValues>;
}) {
  const values = useWatch({ control });

  return <ExpensivePreview values={values} />;
}

如果预览只依赖部分字段:

const values = useWatch({
  control,
  name: ["title", "quantity"],
});

2. 校验计算过重

如果 Schema 含有大量数组、复杂正则或同步计算,onChange 校验会在输入期间反复运行。可考虑:

  • 将主要反馈放在 onBluronSubmit
  • 将昂贵的派生预览从输入组件中拆出;
  • 只对当前步骤或当前数组项触发校验;
  • 将异步唯一性检查做去抖,但不能把去抖结果当作服务端最终判断。

3. 组件卸载和重新挂载

动态数组中使用错误 key、频繁 update 整行或条件渲染结构变化,可能造成:

  • 输入焦点丢失;
  • 本地组件状态重置;
  • 第三方选择器重新初始化;
  • 用户看到输入值闪烁或错位。

此时应查看 React key、useFieldArray 操作方式和条件字段的注册策略,而不是首先增加 memo

4. 无效的 memo 化

如果父组件每次都创建新的大对象或函数,子组件即使使用 memo 也可能继续更新:

<Preview config={{ currency: "CNY" }} />

可以稳定引用:

const config = useMemo(
  () => ({ currency: "CNY" }),
  [],
);

memouseMemouseCallback 应用于已经测量出的瓶颈。它们会增加依赖维护成本,不能替代合理的 RHF 订阅边界。


十一、一个动态、分区且有服务端错误的结构

大型表单可以按功能拆分,但所有字段需要共享同一个 RHF 上下文:

import { FormProvider, useFormContext } from "react-hook-form";

function CustomerSection() {
  const {
    register,
    formState: { errors },
  } = useFormContext<OrderInput>();

  return (
    <section>
      <label htmlFor="customer">客户名称</label>
      <input id="customer" {...register("customer")} />
      {errors.customer && (
        <p role="alert">{errors.customer.message}</p>
      )}
    </section>
  );
}

function OrderPage() {
  const methods = useForm<OrderInput>({
    resolver: zodResolver(orderSchema),
    defaultValues: {
      customer: "",
      items: [{ sku: "", quantity: 1 }],
    },
  });

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(async (data) => {
        // 提交逻辑
      })}>
        <CustomerSection />
        {/* 其他字段区域 */}
        <button type="submit">提交</button>
      </form>
    </FormProvider>
  );
}

FormProvider 通过 React Context 提供表单方法,子组件用 useFormContext 获取。它解决的是组件层级传递问题,但不是性能隔离的自动保证。

如果子组件这样写:

const { watch } = useFormContext();
const values = watch();

它仍可能订阅整个表单。上下文解决“如何访问”,useWatchuseFormState 才解决“订阅多大范围”。


十二、常见失败表现与诊断路径

1. 错误提示一直不出现

检查顺序:

  1. 输入是否真的使用了 {...register("field")}
  2. name 是否与 Schema 路径完全一致;
  3. resolver 是否传入当前 useForm
  4. mode 是否导致校验只在提交或失焦时执行;
  5. 是否误用了 errors.field?.message,但错误实际位于嵌套路径;
  6. 是否把 ref 覆盖,导致字段没有正确注册。

例如 Schema 是:

z.object({
  user: z.object({
    email: z.string().email(),
  }),
});

UI 应读取:

errors.user?.email?.message

而不是:

errors.email?.message

2. 数字校验提示“必须是数字”

代码:

<input type="number" {...register("age")} />

可能提交字符串 "20"。如果 Schema 使用:

age: z.number()

就会失败。

修复方式之一:

<input
  type="number"
  {...register("age", { valueAsNumber: true })}
/>

或者让 Schema 明确负责预处理:

const schema = z.object({
  age: z.coerce.number().int().min(0),
});

两种方式的选择要统一整个项目的数据边界。若同时做多层转换,应检查空值、NaN 和非法字符串的处理结果。

3. 删除动态行后值或错误错位

首先检查:

key={field.id}

而不是:

key={index}

其次确认字段路径使用当前索引:

register(`items.${index}.sku`)

最后检查是否在删除后手动维护了一份与 RHF 数组重复的 React state。两份数组状态如果更新时序不同,就会出现 UI 数量与提交数据不一致。

4. 异步加载数据后表单仍是空的

defaultValues 通常只用于初始化。数据加载完成后需要:

reset(loadedData);

同时决定是否保留用户修改:

reset(loadedData, {
  keepDirtyValues: true,
});

这类选项的具体可用性和语义应以当前 RHF 版本文档为准;核心问题始终是:服务端新数据到达时,哪些字段拥有覆盖权限。

5. 提交按钮一直可点击并导致重复请求

提交状态应来自 RHF:

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

<button disabled={isSubmitting}>提交</button>

但这只控制同一个表单实例的客户端提交过程。服务端仍应考虑重复请求、幂等键、数据库唯一约束和网络重试。

6. 服务端返回错误但客户端没有定位到字段

检查服务端错误路径和前端注册路径是否一致:

前端:items.0.sku
服务端:items[0].sku

如果协议不一致,应在 API 边界统一格式,而不是让每个字段组件自己猜测错误路径。


十三、RHF 与 React 19 的关系

React 19 提供了新的 React 表单相关能力和服务端交互模型,但 RHF 仍然有自己的职责:

  • React 原生能力可以处理某些原生表单提交和服务端动作场景;
  • RHF 提供字段注册、字段级状态、Schema resolver、动态数组和细粒度订阅;
  • 两者可以组合,但不是同一套状态系统。

如果使用 React Server Components 或服务端动作,必须明确:

  1. 哪些代码在服务端执行;
  2. 哪些组件需要浏览器事件,因此必须是客户端组件;
  3. 表单数据在哪一侧进行第一次校验;
  4. 服务端最终是否再次校验;
  5. 服务端错误如何传回客户端字段。

不能因为使用了 React 19 的服务端能力,就认为浏览器中的 RHF 规则能够自动成为服务端安全边界;也不能因为用了 RHF,就自动获得 React 服务端动作的能力。具体框架对客户端入口、服务端路由和动作函数的约束也可能不同,应按框架当前版本的边界执行。


十四、如何选择字段注册、Schema 和动态数组

可以用以下因果关系进行选择:

原生字段

<input {...register("name")} />

适合标准 HTML 控件,代码短,通常有较小的更新范围。

第三方受控组件

<Controller
  name="country"
  control={control}
  render={({ field }) => (
    <CustomSelect {...field} />
  )}
/>

适合值和事件接口不是原生形式的组件。

简单校验

register("name", {
  required: "必填",
  minLength: { value: 3, message: "太短" },
});

适合局部、简单且不需要跨字段复用的规则。

结构化校验

useForm({
  resolver: zodResolver(schema),
});

适合嵌套对象、数组、跨字段关系和客户端/服务端共享规则。

动态数组

useFieldArray({
  control,
  name: "items",
});

适合数组项的增删、移动和稳定身份管理。

性能隔离

useWatch({ control, name: "items" });
useFormState({ control, name: "items" });

适合将订阅范围限制在实际需要的字段或状态上。


十五、最终的数据流检查

一个可靠的 RHF 表单,应能清楚回答以下问题:

输入框由谁持有当前值?
        ↓
register 还是 Controller?
        ↓
字段路径是什么?
        ↓
值是否需要从字符串转换为数字、日期或其他类型?
        ↓
Schema 是否能验证实际运行时的数据?
        ↓
错误路径能否映射到具体控件?
        ↓
动态数组使用了什么稳定 key?
        ↓
字段卸载后值是否保留?
        ↓
客户端通过后,服务端是否重新验证?
        ↓
服务端字段错误如何回填?
        ↓
哪些组件订阅了哪些表单状态?

其中任何一环不明确,都可能出现“界面看起来正常,但提交数据错误”“删除一行后字段错位”“Schema 类型正确却在运行时收到字符串”或“输入一个字符导致整个页面卡顿”等问题。

RHF 的核心不是某个单独的 Hook,而是一套数据流设计:用字段注册连接 DOM 或受控组件,用 Schema 明确运行时约束,用 useFieldArray 管理动态身份,用局部订阅控制更新范围,再把客户端交互与服务端最终校验分开。只有这些部分同时保持一致,表单才既能正确工作,也能在复杂场景下保持可诊断和可维护。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。