react-hook-from从入门到精通

一.react-hook-from是什么?

a.是什么?

你可以把它理解成一个专门帮你管表单的智能表格管理员

它会帮你做这些事:

  1. 记住每个输入框的值
  2. 检查用户有没有填对
  3. 收集所有数据
  4. 在你点提交时统一交给你
  5. 告诉你哪里出错了

所以,react-hook-form 本质上就是:

一个专门管理 React 表单的库。

b.为什么需要它?

简单的说,就是为了更简单的去管理表单,否则填一个内容我们就需要使用useState来定义一个状态。然后错误的话也要自己去定义字段,还有就是校验也需要一个个写。比如下面的代码,只是定义了一点状态,还没有错误的,也没有校验,但是代码已经很多了。

jsx 复制代码
import { useState } from "react";

function App() {
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({
      username,
      email,
      password,
    });
  };
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        placeholder="用户名"
        />

      <input
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="邮箱"
        />

      <input
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="密码"
        type="password"
        />
      <button type="submit">提交</button>
    </form>
  );
}

二.如何使用?

a.简单使用:

先安装包

jsx 复制代码
npm install react-hook-form

直接看最简单的代码:

jsx 复制代码
import { useForm } from "react-hook-form"; // 从 react-hook-form 里引入 useForm 这个工具

function App() {
  const { register, handleSubmit } = useForm(); // 创建表单管理器,并拿出 register 和 handleSubmit 两个常用功能

  const onSubmit = (data) => { // 定义一个函数,表单提交成功后会执行它
    console.log(data); // 把表单收集到的数据打印到控制台
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 表单提交时,先交给 handleSubmit 处理,再调用 onSubmit */}
      <input {...register("username")} placeholder="请输入用户名" /> {/* 注册一个输入框,名字叫 username */}
      
      <input {...register("password")} type="password" placeholder="请输入密码" /> {/* 注册一个密码输入框,名字叫 password */}
      
      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件

1)useForm

这是整个表单的"总入口"。

你可以理解为:

你要使用 _react-hook-form_,就先去领一个"表单管理器"。


2)register

这个是把输入框"登记"到表单系统里。

你可以理解为:

告诉这个工具:"这个输入框归你管了。"

1....register("username") 里的三个点是什么意思?

这是新手常见疑问,我用大白话说。

jsx 复制代码
<input {...register("username")} />

这里的 ... 叫"展开"。

你先不用记专业定义,你可以理解为:

_register("username")_会返回一包这个输入框需要的配置,\ _..._就是把这一包配置摊开,放进这个 input 里。

其实就像:

  • 你买了一套"输入框管理配件"
  • register("username") 把配件包给你
  • ... 帮你把配件安装到这个 input 上

你现在先知道它是"固定写法"就够了,后面你会越来越熟。\ 2.如果没有写register

那这个输入框就没有注册到表单系统里。

结果就是:

  • 页面上能输入
  • 但提交时拿不到它的值

你可以理解为:

这个人填了表,但没登记进系统,所以最后不会被统计进去。


3)handleSubmit

这个是提交时帮你收集数据、处理提交的。

你可以理解为:

用户点提交时,它先把表单整理好,再交给你。


4)formState.errors(这里还没用到,先列出来)

这个是专门放错误信息的地方。

你可以理解为:

哪个输入框出错了,都能在这里找到。

b.表单校验(必填、长度限制、错误提示)

1.写法

规则通常写在 register() 里面。

比如:

jsx 复制代码
<JSX>register("username", { required: "用户名不能为空" })

你可以理解为:

  • 第一个参数 "username":字段名字
  • 第二个参数 { required: "用户名不能为空" }:这个字段的规则

就像你在登记表旁边贴了一张说明:

  • 这一栏必须填
  • 这一栏最少几位
  • 不符合就提示什么

2.例子(必填)

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 用来注册输入框
    handleSubmit, // 用来处理表单提交
    formState: { errors }, // 从表单状态里取出 errors,用来拿错误信息
  } = useForm(); // 创建表单管理器

  const onSubmit = (data) => { // 定义提交成功后的函数
    console.log("提交成功,数据是:", data); // 打印表单数据
  };

  return (
    <div>
      <h1>注册表单</h1> {/* 页面标题 */}

      <form onSubmit={handleSubmit(onSubmit)}> {/* 提交时先经过 react-hook-form 处理 */}
        <div>
          <input
            {...register("username", { required: "用户名不能为空" })} // 注册 username,并设置必填规则
            placeholder="请输入用户名" // 输入框提示文字
          />
          {errors.username && <p>{errors.username.message}</p>} {/* 如果 username 有错误,就显示错误信息 */}
        </div>

        <div>
          <input
            {...register("password", { required: "密码不能为空" })} // 注册 password,并设置必填规则
            type="password" // 设置为密码输入框
            placeholder="请输入密码" // 输入框提示文字
          />
          {errors.password && <p>{errors.password.message}</p>} {/* 如果 password 有错误,就显示错误信息 */}
        </div>

        <button type="submit">提交</button> {/* 提交按钮 */}
      </form>
    </div>
  );
}

export default App; // 导出组件
plain 复制代码
{errors.username && <p>{errors.username.message}</p>}

你可以直接把它理解成一句人话:

如果 _username_有错误,就显示错误提示。

3.例子(最小长度,常见的检验有现成的api配置)

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 处理提交
    formState: { errors }, // 获取错误信息
  } = useForm(); // 创建表单工具

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交成功,数据是:", data); // 打印表单数据
  };

  return (
    <div>
      <h1>注册表单</h1> {/* 标题 */}

      <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
        <div>
          <input
            {...register("username", {
              required: "用户名不能为空", // 用户名必须填写
            })}
            placeholder="请输入用户名" // 提示文字
          />
          {errors.username && <p>{errors.username.message}</p>} {/* 显示用户名错误 */}
        </div>

        <div>
          <input
            {...register("password", {
              required: "密码不能为空", // 密码必须填写
              minLength: {
                value: 6, // 最少 6 位
                message: "密码长度不能少于 6 位", // 不满足时的提示
              },
            })}
            type="password" // 密码框
            placeholder="请输入密码" // 提示文字
          />
          {errors.password && <p>{errors.password.message}</p>} {/* 显示密码错误 */}
        </div>

        <button type="submit">提交</button> {/* 提交按钮 */}
      </form>
    </div>
  );
}

export default App; // 导出组件

required 可以这样写

plain 复制代码
<JSX>required: "密码不能为空"

minLength 常这样写

plain 复制代码
<JSX>minLength: {
  value: 6,
  message: "密码长度不能少于 6 位"
}

你可以理解为:

  • required 只需要表达"要不要填"
  • minLength 需要表达"最少多少位",所以要多写一个 value

也就是说:

  • value:规则本身
  • message:报错提示

4.例子(邮箱验证,正则检验)

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 处理提交
    formState: { errors }, // 获取错误信息
  } = useForm(); // 创建表单管理器

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交成功,数据是:", data); // 打印数据
  };

  return (
    <div>
      <h1>邮箱表单</h1> {/* 标题 */}

      <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
        <div>
          <input
            {...register("email", {
              required: "邮箱不能为空", // 邮箱必须填写
              pattern: {
                value: /^\S+@\S+\.\S+$/, // 一个简单的邮箱格式检查规则
                message: "邮箱格式不正确", // 格式错误时的提示
              },
            })}
            placeholder="请输入邮箱" // 提示文字
            />
          {errors.email && <p>{errors.email.message}</p>} {/* 显示邮箱错误 */}
        </div>

        <button type="submit">提交</button> {/* 提交按钮 */}
      </form>
    </div>
  );
}

export default App; // 导出组件

怎么理解 pattern

你现在可以把它理解成:

一个"格式检查器"。

5.例子(最大长度,最大值,最小值等)

  1. maxLength:最多多少位
jsx 复制代码
maxLength: {
  value: 10,
  message: "用户名最多 10 位"
}

2)min:最小值

jsx 复制代码
min: {
  value: 18,
  message: "年龄不能小于 18 岁"
}

3)max:最大值

jsx 复制代码
max: {
  value: 60,
  message: "年龄不能大于 60 岁"
}

6.例子(重头戏:自定义校验 validate)

1)确认密码例子

确认密码必须和密码完全一样的例子\ 前置知识:\ 在做"确认密码"时,我们需要先知道"密码输入框现在的值是什么"。

这时候就要用 watch

你可以把 watch 理解成:

偷看一下某个字段当前输入的内容

例子

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 处理提交
    watch, // 监听某个字段当前的值
    formState: { errors }, // 获取错误信息
  } = useForm(); // 创建表单工具

  const passwordValue = watch("password"); // 读取 password 输入框当前的值

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交成功,数据是:", data); // 打印数据
  };

  return (
    <div>
      <h1>注册表单</h1> {/* 页面标题 */}

      <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
        <div>
          <input
            {...register("password", {
              required: "密码不能为空", // 密码必填
              minLength: {
                value: 6, // 最少 6 位
                message: "密码长度不能少于 6 位", // 错误提示
              },
            })}
            type="password" // 密码输入框
            placeholder="请输入密码" // 提示文字
          />
          {errors.password && <p>{errors.password.message}</p>} {/* 显示密码错误 */}
        </div>

        <div>
          <input
            {...register("confirmPassword", {
              required: "请再次输入密码", // 确认密码必填
              validate: (value) => {
                return value === passwordValue || "两次输入的密码不一致"; // 如果一致返回 true,不一致返回错误提示
              },
            })}
            type="password" // 确认密码输入框
            placeholder="请再次输入密码" // 提示文字
          />
          {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>} {/* 显示确认密码错误 */}
        </div>

        <button type="submit">注册</button> {/* 提交按钮 */}
      </form>
    </div>
  );
}

export default App; // 导出组件

2)用户名不能是 admin

jsx 复制代码
validate: (value) => {
  if (value === "admin") {
    return "用户名不能是 admin";
  }
  return true;
}

3)优惠码必须以 VIP 开头

jsx 复制代码
validate: (value) => {
  if (value.startsWith("VIP")) {
    return true;
  }
  return "优惠码必须以 VIP 开头";
}

c.默认值、重置表单、获取当前表单值

1)defaultValues

像什么?

像老师提前帮你把表格的一部分内容填好了。

比如:

姓名:小明

邮箱:xiaoming@test.com

你打开表单时,这些内容已经在里面了。

这就叫默认值。

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, handleSubmit } = useForm({
    defaultValues: {
      username: "小明", // username 默认值是 小明
      email: "xiaoming@test.com", // email 默认值是 xiaoming@test.com
    },
  }); // 创建表单工具,并设置默认值

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交的数据是:", data); // 打印表单数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
      <input {...register("username")} placeholder="请输入用户名" /> {/* 注册 username 输入框 */}
      <input {...register("email")} placeholder="请输入邮箱" /> {/* 注册 email 输入框 */}
      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件

2)reset()

像什么?

像你按下"重新填写"按钮,把整张表恢复到初始状态。

比如:

清空所有输入框

或者恢复成最初默认值

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, handleSubmit, reset } = useForm({
    defaultValues: {
      username: "小明", // 用户名默认值
      email: "xiaoming@test.com", // 邮箱默认值
    },
  }); // 创建表单工具,并设置默认值

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交的数据是:", data); // 打印数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
      <input {...register("username")} placeholder="请输入用户名" /> {/* 用户名输入框 */}
      <input {...register("email")} placeholder="请输入邮箱" /> {/* 邮箱输入框 */}

      <button type="submit">提交</button> {/* 提交按钮 */}
      <button type="button" onClick={() => reset()}>重置</button> {/* 点击后重置表单 */}
    </form>
  );
}

export default App; // 导出组件

reset() 有两种常见用法:

用法 1:恢复初始状态

jsx 复制代码
reset();

用法 2:重置为你指定的新值

jsx 复制代码
reset({
  username: "新用户",
  email: "new@test.com"
});

3)getValues()

像什么?

像你在交表前,偷偷低头看一眼:"我现在都填了什么?"

也就是说:

不提交,也能先拿到表单当前的内容。

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, getValues } = useForm(); // 创建表单工具,并取出 getValues

  const handleCheck = () => { // 定义一个查看当前表单值的函数
    const values = getValues(); // 获取当前表单所有值
    console.log("当前表单的值是:", values); // 打印当前值
  };

  return (
    <div>
      <input {...register("username")} placeholder="请输入用户名" /> {/* 用户名输入框 */}
      <input {...register("email")} placeholder="请输入邮箱" /> {/* 邮箱输入框 */}

      <button type="button" onClick={handleCheck}>查看当前值</button> {/* 点击后查看当前表单值 */}
    </div>
  );
}

export default App; // 导出组件

getValues("username") 还能只拿一个字段

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, getValues } = useForm(); // 创建表单工具

  const handleCheckUsername = () => { // 查看用户名的函数
    const username = getValues("username"); // 获取 username 当前值
    console.log("当前用户名是:", username); // 打印用户名
  };

  return (
    <div>
      <input {...register("username")} placeholder="请输入用户名" /> {/* 用户名输入框 */}
      <input {...register("email")} placeholder="请输入邮箱" /> {/* 邮箱输入框 */}

      <button type="button" onClick={handleCheckUsername}>查看用户名</button> {/* 点击后查看用户名 */}
    </div>
  );
}

export default App; // 导出组件

d.setValue、watch,以及表单联动

1)watch()

用来"观察"某个字段当前的值

你可以理解为:

随时偷看这个输入框里现在写了什么

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, watch } = useForm(); // 创建表单工具,并取出 register 和 watch

  const username = watch("username"); // 实时获取 username 输入框当前的值

  return (
    <div>
      <h1>实时预览</h1> {/* 标题 */}

      <input
        {...register("username")} // 注册 username 输入框
        placeholder="请输入用户名" // 提示文字
      />

      <p>你正在输入:{username}</p> {/* 实时显示当前输入内容 */}
    </div>
  );
}

export default App; // 导出组件

2)setValue()

用来"主动修改"某个字段的值

你可以理解为:

我不等用户手动输入,我直接帮他把某个输入框改掉

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, setValue } = useForm(); // 创建表单工具,并取出 setValue

  const fillName = () => { // 定义一个自动填充名字的函数
    setValue("username", "小明"); // 把 username 输入框的值设置成 小明
  };

  return (
    <div>
      <h1>自动填充</h1> {/* 标题 */}

      <input
        {...register("username")} // 注册 username 输入框
        placeholder="请输入用户名" // 提示文字
      />

      <button type="button" onClick={fillName}>一键填入用户名</button> {/* 点击按钮自动填值 */}
    </div>
  );
}

export default App; // 导出组件

真实场景 1:勾选"同意协议"后才能提交

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, watch, handleSubmit } = useForm(); // 创建表单工具

  const agree = watch("agree"); // 监听 agree 复选框当前是否勾选

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交数据:", data); // 打印数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
      <input
        {...register("username")} // 注册用户名输入框
        placeholder="请输入用户名" // 提示文字
      />

      <div>
        <input
          type="checkbox" // 复选框
          {...register("agree")} // 注册 agree 字段
        />
        <span>我已阅读并同意协议</span> {/* 提示文字 */}
      </div>

      <button type="submit" disabled={!agree}>提交</button> {/* 没勾选时不能提交 */}
    </form>
  );
}

export default App; // 导出组件

e.Controller

1)Controller 是什么,为什么需要它,怎么用它?

主要原因就是:register只适合最原始的表单元素,对于自定义组件,第三方组件使用register可能就会失效了。

2)如何使用Controller(基础使用)?

jsx 复制代码
import { useForm, Controller } from "react-hook-form"; // 引入 useForm 和 Controller

function App() {
  const { control, handleSubmit } = useForm(); // 创建表单工具,并取出 control

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交的数据是:", data); // 打印提交数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
      <Controller
        name="username" // 字段名叫 username
        control={control} // 把 control 传给 Controller
        render={({ field }) => ( // 告诉 Controller 最终渲染什么
          <input
            {...field} // 把 field 里的 value、onChange 等连接到 input 上
            placeholder="请输入用户名" // 提示文字
          />
        )}
      />

      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件

上面的列子使用的是原生的input,使用register会更简单,但是这里主要是举例说明Controller怎么使用。

3)如何使用Controller(自定义组件):

jsx 复制代码
import { useForm, Controller } from "react-hook-form"; // 引入 useForm 和 Controller

function MyInput({ value, onChange, placeholder }) { // 自定义输入组件
  return (
    <input
      value={value} // 当前值
      onChange={onChange} // 输入变化时触发
      placeholder={placeholder} // 提示文字
    />
  );
}

function App() {
  const { control, handleSubmit } = useForm({
    defaultValues: {
      username: "", // username 默认值为空
    },
  }); // 创建表单工具

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交的数据是:", data); // 打印数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
      <Controller
        name="username" // 字段名
        control={control} // 传入 control
        render={({ field }) => ( // 渲染输入组件
          <MyInput
            value={field.value} // 把当前值传给自定义组件
            onChange={field.onChange} // 把变化处理传给自定义组件
            placeholder="请输入用户名" // 提示文字
          />
        )}
      />

      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件

4)如何使用Controller(配合校验使用)

jsx 复制代码
import { useForm, Controller } from "react-hook-form"; // 引入 useForm 和 Controller

function MyInput({ value, onChange, placeholder }) { // 自定义输入组件
  return (
    <input
      value={value} // 当前值
      onChange={onChange} // 输入变化时触发
      placeholder={placeholder} // 提示文字
    />
  );
}

function App() {
  const {
    control, // Controller 需要的控制器
    handleSubmit, // 提交处理
    formState: { errors }, // 错误信息
  } = useForm({
    defaultValues: {
      username: "", // 默认值
    },
  }); // 创建表单工具

  const onSubmit = (data) => { // 提交成功函数
    console.log("提交的数据是:", data); // 打印数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 提交表单 */}
      <Controller
        name="username" // 字段名
        control={control} // 传入 control
        rules={{ required: "用户名不能为空" }} // 校验规则
        render={({ field }) => ( // 渲染组件
          <MyInput
            value={field.value} // 当前值
            onChange={field.onChange} // 输入变化处理
            placeholder="请输入用户名" // 提示文字
          />
        )}
      />

      {errors.username && <p>{errors.username.message}</p>} {/* 显示错误提示 */}

      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件

注意: 在 Controller 里,校验规则放在 rules 这个属性里

5)特殊组件核心模板(只要是自定义组件或者第三方组件):

jsx 复制代码
<Controller
  name="字段名"
  control={control}
  rules={{ 校验规则 }}
  render={({ field }) => (
    <你的组件
      value={field.value}
      onChange={field.onChange}
    />
  )}
/>

f.formState 常用状态:提交成功 / 失败怎么处理

1)handleSubmit 不止一个回调

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 提交处理器
    formState: { errors, isSubmitting, isSubmitted, isValid }, // 常用状态
  } = useForm({
    mode: "onChange", // 输入时就更新校验状态(这样 isValid 会实时变化)
  });

  const onValid = async (data) => { // 校验通过时调用
    console.log("提交成功,数据是:", data); // 打印成功数据
    await new Promise((resolve) => setTimeout(resolve, 1000)); // 模拟请求耗时 1 秒
    alert("保存成功!"); // 提示成功
  };

  const onInvalid = (errorInfo) => { // 校验失败时调用
    console.log("提交失败,错误是:", errorInfo); // 打印错误对象
    alert("提交失败,请检查表单"); // 提示失败
  };

  return (
    <form onSubmit={handleSubmit(onValid, onInvalid)}> {/* 提交时分别走成功/失败回调 */}
      <input
        {...register("username", { required: "用户名不能为空" })} // 注册 username 并设置必填
        placeholder="请输入用户名" // 占位提示
      />
      {errors.username && <p>{errors.username.message}</p>} {/* 显示用户名错误 */}

      <input
        type="password" // 密码输入框
        {...register("password", {
          required: "密码不能为空", // 必填
          minLength: { value: 6, message: "密码至少 6 位" }, // 最短 6 位
        })}
        placeholder="请输入密码"
      />
      {errors.password && <p>{errors.password.message}</p>} {/* 显示密码错误 */}

      <button type="submit" disabled={isSubmitting}> {/* 提交中时禁用按钮 */}
        {isSubmitting ? "提交中..." : "提交"} {/* 根据状态切换按钮文字 */}
      </button>

      <p>是否提交过:{isSubmitted ? "是" : "否"}</p> {/* 是否至少提交过一次 */}
      <p>当前是否有效:{isValid ? "是" : "否"}</p> {/* 当前校验是否通过 */}
    </form>
  );
}

export default App; // 导出组件

2)formState 常用状态

a) isSubmitting

你可以理解为"正在提交中"。

true:正在请求(比如等接口返回)

false:没有在提交

常见用法:禁用按钮 + 显示"提交中..."。

b) isSubmitted

你可以理解为"是否提交过至少一次"。

提交过一次就会变成 true

常用于控制"提交后才显示某些提示"

c) isValid

你可以理解为"当前整张表单是否通过校验"。

true:所有规则都通过

false:还有字段不合格

小提醒:想让它"实时更新",常配 mode: "onChange"。

d) errors

保存每个字段的错误信息。

比如:

errors.username?.message

errors.password?.message

g.reset(重置表单)和 setValue(代码里手动改值)

jsx 复制代码
import { useEffect } from "react"; // 引入 useEffect
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 提交处理
    reset, // 重置整张表单
    setValue, // 手动设置某个字段值
    watch, // 观察字段当前值(方便演示)
    formState: { errors }, // 错误信息
  } = useForm({
    defaultValues: { // 表单初始值
      username: "", // 用户名默认空
      email: "", // 邮箱默认空
    },
  });

  const onSubmit = (data) => { // 提交成功回调
    console.log("提交数据:", data); // 打印数据
  };

  useEffect(() => { // 组件加载后模拟"接口回填"
    const timer = setTimeout(() => { // 1 秒后执行
      reset({ username: "小明", email: "xiaoming@test.com" }); // 一次性回填整张表单
    }, 1000);

    return () => clearTimeout(timer); // 组件卸载时清理定时器
  }, [reset]);

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <input
        {...register("username", { required: "用户名不能为空" })} // 用户名必填
        placeholder="用户名"
      />
      {errors.username && <p>{errors.username.message}</p>} {/* 显示用户名错误 */}

      <input
        {...register("email", { required: "邮箱不能为空" })} // 邮箱必填
        placeholder="邮箱"
      />
      {errors.email && <p>{errors.email.message}</p>} {/* 显示邮箱错误 */}

      <p>当前用户名:{watch("username")}</p> {/* 实时显示 username 值 */}

      <button
        type="button" // 普通按钮,不触发表单提交
        onClick={() => setValue("username", "手动改成小红")} // 只修改 username
      >
        只改用户名(setValue)
      </button>

      <button
        type="button" // 普通按钮
        onClick={() => reset()} // 重置回 defaultValues
      >
        重置为初始值(reset)
      </button>

      <button
        type="button" // 普通按钮
        onClick={() => reset({ username: "游客", email: "" })} // 重置到你指定的新值
      >
        重置为"游客"
      </button>

      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件

1)使用场景

setValue("字段名", 新值)

只改一个字段

常用于:点击按钮快速填值、联动修改字段

reset(整包对象)

一次改整张表单

常用于:接口拿到数据后"回填编辑页"

2)常见坑点

a.把 setValue 当成 reset 用

setValue 是"点改";reset 是"整表恢复"。

b.reset() 后发现不是清空

因为它会回到 defaultValues,不是一定变空。

c.按钮忘记写 type="button"


里按钮默认是 submit,可能一点击就提交了。

d.接口回填时不用 reset,用一堆 setValue

小表单还行,大表单很累。回填整包数据优先 reset。

h.watch(监听输入变化)做实时预览与联动

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    watch, // 监听字段变化
    handleSubmit, // 提交处理
  } = useForm({
    defaultValues: { // 默认值
      nickname: "", // 昵称默认空
      city: "北京", // 城市默认北京
    },
  });

  const nickname = watch("nickname"); // 实时拿到 nickname 当前值
  const city = watch("city"); // 实时拿到 city 当前值

  const onSubmit = (data) => { // 提交回调
    console.log("提交数据:", data); // 打印提交数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <input
        {...register("nickname")} // 注册昵称字段
        placeholder="请输入昵称"
      />

      <select {...register("city")}> {/* 注册城市下拉框 */}
        <option value="北京">北京</option> {/* 选项:北京 */}
        <option value="上海">上海</option> {/* 选项:上海 */}
        <option value="广州">广州</option> {/* 选项:广州 */}
      </select>

      <h3>实时预览:</h3> {/* 标题 */}
      <p>昵称:{nickname || "(还没输入)"}</p> {/* 实时显示昵称 */}
      <p>城市:{city}</p> {/* 实时显示城市 */}

      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件
jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const { register, watch } = useForm(); // 解构 register 和 watch
  const city = watch("city", "北京"); // 监听 city,默认北京

  return (
    <div>
      <select {...register("city")}> {/* 注册城市 */}
        <option value="北京">北京</option>
        <option value="上海">上海</option>
      </select>

      {city === "北京" && <p>当前配送时效:次日达</p>} {/* 北京时的文案 */}
      {city === "上海" && <p>当前配送时效:当日达</p>} {/* 上海时的文案 */}
    </div>
  );
}

export default App; // 导出组件

1)watch 常见用法(你先会这 3 个)

a. watch("字段名")

监听单个字段

b. watch(["a", "b"])

同时监听多个字段

c. watch()

监听整张表单(初学阶段少用,容易频繁刷新)

2)常见坑点

a.一上来就 watch() 全量监听

可能导致页面频繁更新。

建议先精准监听:watch("nickname")。

b.忘了给默认值,首次是 undefined

可以在 useForm({ defaultValues }) 里给,

或 watch("city", "北京") 给兜底值。

c.把 watch 当"提交数据"用

watch 是"实时看值",

真正提交仍然靠 handleSubmit(onSubmit)

i.Controller:对接"第三方输入组件"

jsx 复制代码
interface FormMyDatePickerData {
  birthday: string;
}

function ControllerUseMyDatePickerFrom() {
  const {
    control, // 给 Controller 用的控制器
    handleSubmit, // 提交处理
    formState: { errors }, // 错误信息
  } = useForm<FormMyDatePickerData>({
    defaultValues: {
      birthday: "", // 生日默认值
    },
  });

  const onSubmit = (data: FormMyDatePickerData) => { // 提交回调
    console.log("提交数据:", data); // 打印数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <Controller
        name="birthday" // 字段名
        control={control} // 传入 control
        rules={{ required: "请选择生日" }} // 校验规则
        render={({ field }) => ( // field 里有 value/onChange/onBlur/name/ref
          <MyDatePicker
            value={field.value} // 把表单值传给组件
            onChange={field.onChange} // 把组件变化同步回表单
          />
        )}
      />

      {errors.birthday && <p>{errors.birthday.message}</p>} {/* 显示错误 */}

      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

1)Controller 你先记住这 4 个点

a.必须有 name

这是字段名字。

b.必须有 control

从 useForm() 里解构出来。

c.用 render 把字段"接线"给组件

重点是把 field.value、field.onChange 传进去。

d.校验规则写在 rules

跟 register 里的规则类似。

2)和 register 的区别(超简版)

原生表单控件:优先 register

第三方复杂组件:用 Controller

你可以理解为:

register 是"直连",Controller 是"转接头"。

3)坑点

a.忘传 control

Controller 就不会工作。

b.只传 value,没传 onChange

看起来有值,但改不了(或改了不同步)。

c.defaultValues 没给,出现受控/非受控警告

尤其是日期、Select 组件,建议给初始值。

d.第三方组件的 onChange 参数格式不一样

有些不是 event,而是直接给值对象。

这时你要手动转换后再 field.onChange(...)。

j.useFieldArray:动态增删表单项(重复表单块)

使用场景如下图:

1)基础示例

jsx 复制代码
import { useForm, useFieldArray } from "react-hook-form"; // 引入 useForm 和 useFieldArray

function App() {
  const { control, register, handleSubmit } = useForm({ // 创建表单
    defaultValues: { // 默认值
      contacts: [ // contacts 是一个数组字段
        { name: "", phone: "" }, // 默认先给一条联系人
      ],
    },
  });

  const { fields, append, remove } = useFieldArray({ // 管理 contacts 这个数组
    control, // 传入 control
    name: "contacts", // 指定数组字段名
  });

  const onSubmit = (data) => { // 提交回调
    console.log("提交数据:", data); // 打印最终数据
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <h3>联系人列表</h3> {/* 标题 */}

      {fields.map((item, index) => ( // 遍历每一条联系人
        <div key={item.id} style={{ marginBottom: 12 }}> {/* key 必须用 item.id */}
          <input
            placeholder="姓名"
            {...register(`contacts.${index}.name`)} // 注册第 index 条的 name
          />
          <input
            placeholder="手机号"
            {...register(`contacts.${index}.phone`)} // 注册第 index 条的 phone
          />

          <button
            type="button" // 普通按钮,避免触发表单提交
            onClick={() => remove(index)} // 删除当前这一条
          >
            删除
          </button>
        </div>
      ))}

      <button
        type="button" // 普通按钮
        onClick={() => append({ name: "", phone: "" })} // 新增一条空联系人
      >
        新增联系人
      </button>

      <button type="submit">提交</button> {/* 提交按钮 */}
    </form>
  );
}

export default App; // 导出组件

2)核心api

a.fields

当前"可渲染的列表项"

你用它 map 出多组输入框

b.append(新对象)

在末尾新增一条

c.remove(index)

删除某一条

3)坑点:

a.key 用了 index,没用 item.id

可能导致输入框错位、值串行。

记住:key={item.id}。

b.字段路径写错

要写成:contacts.${index}.name 这种格式。

少一个点都不行。

c.新增按钮没写 type="button"

会误触发表单提交。

d.append 的对象结构不完整

你有 name/phone 两个字段,就尽量都给上初始值。

k.reset(编辑页回填实战)

1)使用场景(编辑回填)

你可以理解为这个场景:

  • 新增页:表单是空的
  • 编辑页:要先请求旧数据,再把旧数据"塞回表单"让用户改

在 React Hook Form 里,最常用的方法就是:**reset()**

jsx 复制代码
import { useEffect } from "react"; // 引入 useEffect
import { useForm, useFieldArray } from "react-hook-form"; // 引入 hooks

function App() {
  const {
    control, // 给 useFieldArray 和 Controller 用
    register, // 注册输入框
    handleSubmit, // 提交处理
    reset, // 回填核心:重置整张表单
  } = useForm({
    defaultValues: { // 先给一个基础结构,避免初始 undefined
      username: "", // 用户名默认空
      email: "", // 邮箱默认空
      contacts: [{ name: "", phone: "" }], // 默认一条联系人
    },
  });

  const { fields, append, remove } = useFieldArray({
    control, // 传入 control
    name: "contacts", // 数组字段名
  });

  useEffect(() => {
    async function fetchUserDetail() { // 模拟请求编辑详情
      const apiData = await Promise.resolve({ // 假装接口返回的数据
        username: "小明", // 接口用户名
        email: "xiaoming@test.com", // 接口邮箱
        contacts: [ // 接口联系人数组
          { name: "妈妈", phone: "13800000000" },
          { name: "同事", phone: "13900000000" },
        ],
      });

      reset(apiData); // 一次性把接口数据回填到整张表单
    }

    fetchUserDetail(); // 页面加载后执行
  }, [reset]); // 依赖 reset

  const onSubmit = (data) => { // 提交回调
    console.log("提交数据:", data); // 打印最终提交内容
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <h3>编辑用户</h3>

      <input
        placeholder="用户名"
        {...register("username")} // 注册 username
      />

      <input
        placeholder="邮箱"
        {...register("email")} // 注册 email
      />

      <h4>联系人</h4>
      {fields.map((item, index) => (
        <div key={item.id}> {/* key 必须用 item.id */}
          <input
            placeholder="联系人姓名"
            {...register(`contacts.${index}.name`)} // 第 index 条联系人姓名
          />
          <input
            placeholder="联系人电话"
            {...register(`contacts.${index}.phone`)} // 第 index 条联系人电话
          />
          <button type="button" onClick={() => remove(index)}>删除</button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ name: "", phone: "" })} // 新增联系人
      >
        新增联系人
      </button>

      <button type="submit">保存</button>
    </form>
  );
}

export default App; // 导出组件

2)关键点

a.接口数据到了以后,用 reset(data) 回填

最省心,整表更新。

b.defaultValues 先给"形状"

尤其是数组字段(如 contacts),先给结构更稳。

c.动态数组也能跟着 reset 一起回填

只要 data.contacts 是数组,useFieldArray 会正确渲染条数。

3)坑点:

a.把回填写成一堆 setValue

不是不行,但字段多时很痛苦。

编辑页优先 reset。

b.接口字段名和表单字段名对不上

比如接口是 user_name,表单是 username。

需要先做一层映射再 reset。

c.接口返回 contacts: null 导致报错

你可以兜底:contacts: api.contacts ?? []。

d.先渲染后回填时出现"闪一下空表单"

常见现象。可加 loading,数据到位再显示表单。

4)总结:

编辑页回填的标准动作:__请求详情 → 数据映射 → _**reset(mappedData)**_

l.isSubmitting:提交状态与防重复提交(按钮 loading处理 + 错误处理)

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 表单提交处理器
    setError, // 手动设置字段错误
    formState: { errors, isSubmitting }, // errors 错误对象,isSubmitting 提交中状态
  } = useForm({
    defaultValues: { // 默认值
      username: "", // 用户名默认空
      password: "", // 密码默认空
    },
  });

  const onSubmit = async (data) => { // 提交函数(异步)
    try {
      await new Promise((resolve) => setTimeout(resolve, 1500)); // 模拟网络请求 1.5 秒

      if (data.username === "admin") { // 模拟后端返回"用户名已存在"
        setError("username", { type: "server", message: "这个用户名已被占用" }); // 把后端错误显示到字段上
        return; // 终止后续逻辑
      }

      alert("提交成功!"); // 成功提示
    } catch (err) {
      alert("网络异常,请稍后重试"); // 全局失败提示
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <div>
        <input
          placeholder="用户名"
          {...register("username", { required: "请输入用户名" })} // 必填校验
        />
        {errors.username && <p>{errors.username.message}</p>} {/* 显示用户名错误 */}
      </div>

      <div>
        <input
          type="password"
          placeholder="密码"
          {...register("password", { required: "请输入密码", minLength: { value: 6, message: "至少6位" } })} // 密码校验
        />
        {errors.password && <p>{errors.password.message}</p>} {/* 显示密码错误 */}
      </div>

      <button type="submit" disabled={isSubmitting}> {/* 提交中禁用按钮,防重复提交 */}
        {isSubmitting ? "提交中..." : "登录"} {/* 根据状态切换文案 */}
      </button>
    </form>
  );
}

export default App; // 导出组件

1)核心关键点

a.isSubmitting

提交开始后自动变 true

提交结束后自动回 false

b.按钮 disabled={isSubmitting}

防止用户重复点击

c.错误分两类处理

字段错误:setError("字段名", { message: "..." })

全局错误:toast / alert(比如网络错误)

2)坑点

a.忘了把 onSubmit 写成 async

isSubmitting 的体验会不对。

b.按钮没禁用

用户连点触发多次请求,可能生成重复数据。

c.后端报错只 console.log,不反馈给用户

用户不知道发生了什么,会反复点。

d.把"字段错误"和"全局错误"混在一起

建议区分:

哪个输入框有问题 → 显示在输入框下

系统挂了/超时 → 全局提示

3)总结

专业表单提交 = _isSubmitting_控状态 + 禁用按钮防连点 + 给用户清晰错误反馈。

j.提交后把后端错误显示在页面上

1)使用setError回显

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 提交处理
    setError, // 手动设置字段错误
    formState: { errors, isSubmitting }, // 取错误和提交状态
  } = useForm({
    defaultValues: { // 默认值
      email: "", // 邮箱
      phone: "", // 手机号
      password: "", // 密码
    },
  });

  const fakeApi = async (data) => { // 模拟请求函数
    await new Promise((r) => setTimeout(r, 1000)); // 模拟网络延迟

    return { // 模拟后端返回失败
      ok: false, // 请求结果失败
      message: "校验失败", // 全局消息
      errors: { // 字段级错误
        email: "邮箱已被注册",
        phone: "手机号格式不正确",
      },
    };
  };

  const onSubmit = async (formData) => { // 提交函数
    const res = await fakeApi(formData); // 调接口

    if (!res.ok) { // 如果失败
      if (res.errors) { // 如果有字段错误
        Object.entries(res.errors).forEach(([field, message]) => { // 遍历错误对象
          setError(field, { type: "server", message }); // 映射到 RHF 字段错误
        });
      }

      if (res.message) { // 如果有全局消息
        alert(res.message); // 给一个全局提示
      }

      return; // 结束
    }

    alert("提交成功"); // 成功提示
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <div>
        <input
          placeholder="邮箱"
          {...register("email", {
            required: "请输入邮箱", // 前端必填
            pattern: { value: /^\S+@\S+\.\S+$/, message: "邮箱格式不正确" }, // 前端格式校验
          })}
        />
        {errors.email && <p>{errors.email.message}</p>} {/* 展示邮箱错误 */}
      </div>

      <div>
        <input
          placeholder="手机号"
          {...register("phone", { required: "请输入手机号" })} // 前端必填
        />
        {errors.phone && <p>{errors.phone.message}</p>} {/* 展示手机号错误 */}
      </div>

      <div>
        <input
          type="password"
          placeholder="密码"
          {...register("password", { required: "请输入密码", minLength: { value: 6, message: "至少6位" } })} // 密码校验
        />
        {errors.password && <p>{errors.password.message}</p>} {/* 展示密码错误 */}
      </div>

      <button type="submit" disabled={isSubmitting}> {/* 提交中禁用 */}
        {isSubmitting ? "提交中..." : "注册"} {/* 文案切换 */}
      </button>
    </form>
  );
}

export default App; // 导出组件

2)关键理解

a.前端校验负责"即时体验"

比如空值、长度、格式,用户输入时就能知道。

b.后端校验负责"最终正确性"

比如"邮箱是否已存在"这种必须问数据库的规则,只能后端判断。

c.后端错误要"按字段落地"

setError("email", { message: "..." })

这样用户一眼就知道该改哪个框。

3)常见坑点

a.后端字段名和前端字段名不一致

后端 user_email,前端 email。

需要做映射表,不然 setError 对不上。

b.只弹 toast,不显示字段错误

用户知道"失败了",但不知道改哪里。

c.后端返回数组错误,没处理

有些接口返回 errors: [{field, message}],要先转换成对象或循环设置。

d.把后端错误覆盖前端交互节奏

建议:先过前端校验,再请求后端。体验更顺滑。

4)总结

前后端校验协同的核心:__前端做快反馈,后端做最终裁决,错误用 _**setError**_精准贴回字段。

k.watch 与联动表单(一个字段变化,带动另一个字段)

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

function App() {
  const {
    register, // 注册输入框
    handleSubmit, // 提交处理器
    watch, // 监听字段变化
    formState: { errors }, // 取错误对象
  } = useForm({
    defaultValues: { // 默认值
      needInvoice: "no", // 默认不开发票
      invoiceTitle: "", // 发票抬头
      taxNo: "", // 税号
    },
  });

  const needInvoice = watch("needInvoice"); // 实时监听 needInvoice 当前值

  const onSubmit = (data) => { // 提交函数
    console.log("提交数据:", data); // 打印数据
    alert(JSON.stringify(data, null, 2)); // 弹窗查看结果
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <h3>订单信息</h3>

      <label>
        <input
          type="radio"
          value="no"
          {...register("needInvoice")} // 注册"不开发票"
        />
        不开发票
      </label>

      <label>
        <input
          type="radio"
          value="yes"
          {...register("needInvoice")} // 注册"开发票"
        />
        开发票
      </label>

      {needInvoice === "yes" && ( // 当选择开发票时才显示下面字段
        <div style={{ marginTop: 12 }}>
          <div>
            <input
              placeholder="发票抬头"
              {...register("invoiceTitle", {
                required: "请输入发票抬头", // 仅显示时需要填写
              })}
            />
            {errors.invoiceTitle && <p>{errors.invoiceTitle.message}</p>} {/* 显示错误 */}
          </div>

          <div>
            <input
              placeholder="税号"
              {...register("taxNo", {
                required: "请输入税号", // 仅显示时需要填写
              })}
            />
            {errors.taxNo && <p>{errors.taxNo.message}</p>} {/* 显示错误 */}
          </div>
        </div>
      )}

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

export default App; // 导出组件

1) 坑点:

a.隐藏字段还在报错

你把字段隐藏了,但错误还在。

你可以理解为"虽然看不见,但表单状态里它还存在"。

解决思路:

切换时清空相关值 / 清除错误

或用 shouldUnregister: true(让卸载字段从表单中移除)

b.watch 过多导致页面频繁渲染

监听很多字段时,组件会更频繁刷新。

小项目问题不大,大表单要注意拆组件。

c.联动后忘记处理默认值

比如编辑页回填时,如果 needInvoice 是 yes,要确保发票字段也有默认值或回填值。

2)总结

_watch_就是"表单里的实时观察员":盯住某个值,一变就触发 UI 联动。

l.FormProvider / useFormContext(大表单拆组件必会)

jsx 复制代码
import { useForm, FormProvider, useFormContext } from "react-hook-form"; // 引入 RHF 相关 API

interface FormData {
  username: string;
  email: string;
}

function UsernameField() { // 子组件:用户名输入
  const {
    register, // 从上下文拿 register
    formState: { errors }, // 从上下文拿 errors
  } = useFormContext<FormData>(); // 关键:不接收 props,直接拿父表单上下文

  return (
    <div>
      <input
        placeholder="请输入用户名"
        {...register("username", {
          required: "用户名必填", // 必填校验
          minLength: { value: 2, message: "至少 2 个字符" }, // 最小长度校验
        })}
      />
      {errors.username && <p>{errors.username.message}</p>} {/* 显示错误 */}
    </div>
  );
}

function EmailField() { // 子组件:邮箱输入
  const {
    register, // 从上下文拿 register
    formState: { errors }, // 从上下文拿 errors
  } = useFormContext<FormData>(); // 使用上下文

  return (
    <div>
      <input
        placeholder="请输入邮箱"
        {...register("email", {
          required: "邮箱必填", // 必填
          pattern: {
            value: /^\S+@\S+\.\S+$/, // 简单邮箱正则
            message: "邮箱格式不正确", // 格式错误提示
          },
        })}
      />
      {errors.email && <p>{errors.email.message}</p>} {/* 显示错误 */}
    </div>
  );
}

export default function UseFormContext() { // 父组件
  const methods = useForm<FormData>({
    defaultValues: {
      username: "", // 默认用户名
      email: "", // 默认邮箱
    },
    mode: "onBlur", // 失焦校验
  });

  const onSubmit = (data: FormData) => { // 提交函数
    alert(JSON.stringify(data, null, 2)); // 展示提交结果
  };

  return (
    <FormProvider {...methods}> {/* 把整个表单方法"注入"上下文 */}
      <form onSubmit={methods.handleSubmit(onSubmit)}> {/* 提交绑定 */}
        <UsernameField /> {/* 子组件1 */}
        <EmailField /> {/* 子组件2 */}
        <button type="submit">提交</button> {/* 提交按钮 */}
      </form>
    </FormProvider>
  );
}

1)3 个重点

a.FormProvider 放在表单外层

把 useForm() 的返回值 ...methods 传进去。

b.子组件里用 useFormContext() 拿能力

不再手动传 register/errors/control。

c.特别适合大表单拆模块

比如"基础信息模块""地址模块""发票模块"。

2)坑点

a.忘了包 FormProvider

子组件调用 useFormContext() 会拿不到值(直接报错)。

b.子组件里又 useForm() 一次

会变成"两个表单系统",数据不通。

子组件要用 useFormContext(),不要重复开新表单。

c.name 冲突

不同子组件注册了同名字段,值会互相覆盖。

d.深层组件错误提示取错路径

像 user.email 这种嵌套字段,要注意 errors.user?.email?.message。

3)必知

这里是必须学会FormProvider / useFormContext是如何使用的,因为在项目中不会用像之前前面的例子那样子使用,基本上都是分组件使用,然后配合Controller封装第三方组件来作为form的一员。这个例子的结构很经典,值得多次学习

m.Zod + resolver(把校验规则集中管理)

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm
import { z } from "zod"; // 引入 zod
import { zodResolver } from "@hookform/resolvers/zod"; // 引入 zod 适配器

const schema = z.object({ // 定义整张表单的校验规则
  username: z
    .string()
    .min(2, "用户名至少 2 个字符"),
  email: z.email("邮箱格式不正确"), // Zod v4:email 已是独立 schema,不再挂在 .string() 上
  age: z
    .string()
    .refine((v) => /^\d+$/.test(v), "年龄必须是整数") // 必须全是数字
    .refine((v) => parseInt(v, 10) >= 18, "年龄不能小于 18"), // 最小 18
  password: z
    .string()
    .min(6, "密码至少 6 位"),
  confirmPassword: z
    .string()
    .min(6, "确认密码至少 6 位"),
}).refine((data) => data.password === data.confirmPassword, { // 跨字段校验
  message: "两次密码不一致",
  path: ["confirmPassword"],
});

// 直接用 z.infer 派生,输入输出类型一致,不再分叉
type FormData = z.infer<typeof schema>;

export default function App() { // 主组件
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(schema),
    mode: "onBlur",
    defaultValues: {
      username: "",
      email: "",
      age: "", // age 始终是字符串,无类型冲突
      password: "",
      confirmPassword: "",
    },
  });

  const onSubmit = (data: FormData) => {
    alert("提交成功:\n" + JSON.stringify(data, null, 2));
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input placeholder="用户名" {...register("username")} />
        {errors.username && <p>{errors.username.message}</p>}
      </div>

      <div>
        <input placeholder="邮箱" {...register("email")} />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <div>
        <input placeholder="年龄" {...register("age")} />
        {errors.age && <p>{errors.age.message}</p>}
      </div>

      <div>
        <input type="password" placeholder="密码" {...register("password")} />
        {errors.password && <p>{errors.password.message}</p>}
      </div>

      <div>
        <input
          type="password"
          placeholder="确认密码"
          {...register("confirmPassword")}
        />
        {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
      </div>

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

1)优点

a.把校验写在 register("xx", { required... }) 里,字段一多会很分散。\ 现在我们把规则"统一写在一个文件/一个对象里",表单只负责渲染。

b.规则集中管理:一眼看全表单规则

c.跨字段校验:比如"确认密码必须一致"

d.类型更清晰:尤其 TS 项目里很香(后面你可以再进阶)

n.实战表单(新增/编辑共用一个组件)

jsx 复制代码
import { useEffect, useState } from "react"; // 引入 React 和 hooks
import { useForm } from "react-hook-form"; // 引入 RHF
import { z } from "zod"; // 引入 zod
import { zodResolver } from "@hookform/resolvers/zod"; // 引入 zod 解析器

interface User {
  id: number;
  name: string;
  email: string;
  role: "user" | "admin";
}

const userSchema = z.object({ // 定义表单校验规则
  name: z.string().min(2, "姓名至少 2 个字符"), // name 校验
  email: z.email("邮箱格式不正确"), // email 校验
  role: z.enum(["user", "admin"], { message: "角色不合法" }), // role 必须是 user/admin
});

type UserFormValues = z.infer<typeof userSchema>; // 从 schema 推断表单字段类型(不含 id)

function UserForm({ initialData, onCreate, onUpdate }: { initialData: User | null, onCreate: (values: UserFormValues) => void, onUpdate: (id: number, values: UserFormValues) => void }) { // 共用表单组件
  const isEditMode = !!initialData?.id; // 有 id 就是编辑模式

  const {
    register, // 注册字段
    handleSubmit, // 提交处理
    reset, // 重置/回填表单
    formState: { errors, isSubmitting }, // 错误对象 + 提交中状态
  } = useForm<UserFormValues>({
    resolver: zodResolver(userSchema), // 接入 zod
    defaultValues: {
      name: "", // 默认 name
      email: "", // 默认 email
      role: "user", // 默认 role
    },
  });

  useEffect(() => { // 监听 initialData 变化
    if (initialData) { // 如果有初始数据(编辑)
      reset({
        name: initialData.name ?? "", // 回填 name
        email: initialData.email ?? "", // 回填 email
        role: initialData.role ?? "user", // 回填 role
      });
    } else { // 没有初始数据(新增)
      reset({
        name: "", // 清空 name
        email: "", // 清空 email
        role: "user", // 还原 role 默认值
      });
    }
  }, [initialData, reset]); // 依赖:initialData 或 reset 变化时执行

  const onSubmit = async (values: UserFormValues) => { // 提交函数
    if (isEditMode) { // 编辑模式
      await onUpdate(initialData.id, values); // 调用更新
    } else { // 新增模式
      await onCreate(values); // 调用创建
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <h3>{isEditMode ? "编辑用户" : "新增用户"}</h3> {/* 标题根据模式切换 */}

      <div>
        <input placeholder="姓名" {...register("name")} /> {/* 注册 name */}
        {errors.name && <p>{errors.name.message}</p>} {/* 显示 name 错误 */}
      </div>

      <div>
        <input placeholder="邮箱" {...register("email")} /> {/* 注册 email */}
        {errors.email && <p>{errors.email.message}</p>} {/* 显示 email 错误 */}
      </div>

      <div>
        <select {...register("role")}> {/* 注册 role */}
          <option value="user">普通用户</option> {/* user 选项 */}
          <option value="admin">管理员</option> {/* admin 选项 */}
        </select>
        {errors.role && <p>{errors.role.message}</p>} {/* 显示 role 错误 */}
      </div>

      <button type="submit" disabled={isSubmitting}> {/* 提交按钮,提交中禁用 */}
        {isSubmitting ? "提交中..." : isEditMode ? "保存修改" : "创建用户"}
      </button>
    </form>
  );
}

export default function Demo() { // 页面组件(模拟列表 + 选中编辑)
  const [editingUser, setEditingUser] = useState<User | null>(null); // 当前编辑用户
  const [users, setUsers] = useState([ // 模拟用户列表
    { id: 1, name: "张三", email: "zhangsan@test.com", role: "user" },
    { id: 2, name: "李四", email: "lisi@test.com", role: "admin" },
  ]);

  const handleCreate = async (values: UserFormValues) => { // 创建逻辑
    const newUser = { ...values, id: Date.now() }; // 生成新用户
    setUsers((prev) => [newUser, ...prev]); // 插入列表
    setEditingUser(null); // 清空编辑态(保持新增状态)
    alert("创建成功"); // 提示
  };

  const handleUpdate = async (id: number, values: UserFormValues) => { // 更新逻辑
    setUsers((prev) =>
      prev.map((u) => (u.id === id ? { ...u, ...values } : u))
    ); // 按 id 更新用户
    setEditingUser(null); // 保存后退出编辑态
    alert("更新成功"); // 提示
  };

  return (
    <div style={{ display: "grid", gap: 16 }}>
      <UserForm
        initialData={editingUser} // 传入当前编辑数据(为空则新增)
        onCreate={handleCreate} // 传入创建函数
        onUpdate={handleUpdate} // 传入更新函数
      />

      <hr />

      <h3>用户列表</h3>
      {users.map((u) => (
        <div key={u.id} style={{ display: "flex", gap: 8 }}>
          <span>
            {u.name} - {u.email} - {u.role}
          </span>
          <button onClick={() => setEditingUser(u as User)}>编辑</button> {/* 进入编辑 */}
          <button onClick={() => setEditingUser(null)}>新增模式</button> {/* 切新增 */}
        </div>
      ))}
    </div>
  );
}

1)4个关键点

a. initialData:是否有初始数据决定新增/编辑

b. reset(...):切换编辑对象时必须回填,不要只靠 defaultValues

c. isEditMode:!!initialData?.id 一把梭判断模式

d. onCreate/onUpdate 分离:提交逻辑更清楚,后期接接口方便

2)常见坑点

a.以为 defaultValues 会自动跟着 props 变

不会。defaultValues 只在初始化生效。

所以编辑切换时要 reset(newData)。

b.编辑 A 后切到新增,旧值还在

说明你没在 initialData 变空时 reset 成空值。

c.提交按钮重复点导致多次请求

用 isSubmitting 禁用按钮。

d.把 id 也放进表单字段里乱改

通常 id 不让用户编辑,更新时从 initialData.id 取。

o.封装可复用表单组件

抽成两个组件:

FormInput:文本输入框

FormSelect:下拉框

jsx 复制代码
import React from "react"; // 引入 React
import { useForm, FormProvider, useFormContext } from "react-hook-form"; // 引入 RHF 相关 API
import { z } from "zod"; // 引入 zod
import { zodResolver } from "@hookform/resolvers/zod"; // 引入 zod resolver

const schema = z.object({ // 定义校验规则
  name: z.string().min(2, "姓名至少 2 个字符"), // name 规则
  email: z.string().email("邮箱格式不正确"), // email 规则
  role: z.enum(["user", "admin"], { message: "角色不合法" }), // role 规则
});

function FormInput({ name, label, placeholder, type = "text" }) { // 通用输入框组件
  const {
    register, // 从上下文拿 register
    formState: { errors }, // 从上下文拿 errors
  } = useFormContext(); // 读取 FormProvider 提供的表单上下文

  const errorMessage = errors?.[name]?.message; // 读取当前字段错误信息

  return (
    <div style={{ marginBottom: 12 }}> {/* 每个字段下方留点间距 */}
      <label style={{ display: "block", marginBottom: 4 }}>{label}</label> {/* 字段标题 */}
      <input
        type={type} // 输入框类型
        placeholder={placeholder} // 占位提示
        {...register(name)} // 注册字段
      />
      {errorMessage && <p style={{ color: "red" }}>{String(errorMessage)}</p>} {/* 显示错误 */}
    </div>
  );
}

function FormSelect({ name, label, options }) { // 通用下拉组件
  const {
    register, // 从上下文拿 register
    formState: { errors }, // 从上下文拿 errors
  } = useFormContext(); // 读取上下文

  const errorMessage = errors?.[name]?.message; // 读取当前字段错误

  return (
    <div style={{ marginBottom: 12 }}> {/* 字段容器 */}
      <label style={{ display: "block", marginBottom: 4 }}>{label}</label> {/* 字段标题 */}
      <select {...register(name)}> {/* 注册字段 */}
        {options.map((item) => (
          <option key={item.value} value={item.value}> {/* 渲染选项 */}
            {item.label}
          </option>
        ))}
      </select>
      {errorMessage && <p style={{ color: "red" }}>{String(errorMessage)}</p>} {/* 显示错误 */}
    </div>
  );
}

export default function UserFormPage() { // 页面组件
  const methods = useForm({
    resolver: zodResolver(schema), // 接入 zod 校验
    defaultValues: {
      name: "", // 默认 name
      email: "", // 默认 email
      role: "user", // 默认 role
    },
    mode: "onBlur", // 失焦校验
  });

  const onSubmit = (values) => { // 提交成功回调
    alert("提交成功:\n" + JSON.stringify(values, null, 2)); // 展示数据
  };

  return (
    <FormProvider {...methods}> {/* 把整个表单方法通过上下文传下去 */}
      <form onSubmit={methods.handleSubmit(onSubmit)}> {/* 绑定提交 */}
        <h3>用户表单(封装版)</h3>

        <FormInput
          name="name" // 字段名
          label="姓名" // 标签文本
          placeholder="请输入姓名" // 占位符
        />

        <FormInput
          name="email" // 字段名
          label="邮箱" // 标签文本
          placeholder="请输入邮箱" // 占位符
          type="email" // 输入类型
        />

        <FormSelect
          name="role" // 字段名
          label="角色" // 标签文本
          options={[
            { label: "普通用户", value: "user" }, // 选项1
            { label: "管理员", value: "admin" }, // 选项2
          ]}
        />

        <button type="submit" disabled={methods.formState.isSubmitting}> {/* 提交按钮 */}
          {methods.formState.isSubmitting ? "提交中..." : "提交"}
        </button>
      </form>
    </FormProvider>
  );
}

1)要点:

需要包FormProvider,子组件里使用 useFormContext()拿到数据

p.useController的基本使用

1)useController是什么?

你可以理解为:

Controller 是"组件写法"(JSX 包一层)

useController 是"Hook 写法"(先拿到 field,再自己随便渲染)

两者本质一样,只是写法不同。

2)对比下:

直接用 Controller

jsx 复制代码
import React from "react"; // 引入 React
import { useForm, Controller } from "react-hook-form"; // 引入 useForm 和 Controller

function MySwitch({ value, onChange }) { // 自定义开关组件
  return (
    <button type="button" onClick={() => onChange(!value)}> {/* 点击切换 */}
      {value ? "开" : "关"} {/* 根据 value 显示 */}
    </button>
  );
}

export default function DemoController() { // 页面组件
  const { control, handleSubmit } = useForm({
    defaultValues: { agree: false }, // 默认值
  });

  const onSubmit = (data) => console.log(data); // 提交回调

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <Controller
        name="agree" // 字段名
        control={control} // 表单控制器
        rules={{ required: "请先同意" }} // 校验规则
        render={({ field, fieldState }) => ( // 在 render 里拿到 field
          <div>
            <p>是否同意</p>
            <MySwitch
              value={field.value} // 当前值
              onChange={field.onChange} // 值变化回传
            />
            {fieldState.error && <p style={{ color: "red" }}>{fieldState.error.message}</p>} {/* 错误 */}
          </div>
        )}
      />

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

使用 useController

jsx 复制代码
import React from "react"; // 引入 React
import { useForm, useController } from "react-hook-form"; // 引入 useForm 和 useController

function MySwitch({ value, onChange }) { // 自定义开关组件
  return (
    <button type="button" onClick={() => onChange(!value)}> {/* 点击切换 */}
      {value ? "开" : "关"} {/* 根据 value 显示 */}
    </button>
  );
}

function AgreeField({ control }) { // 单独封装字段组件
  const { field, fieldState } = useController({
    name: "agree", // 字段名
    control, // 表单控制器
    rules: { required: "请先同意" }, // 校验规则
    defaultValue: false, // 默认值(建议写上)
  });

  return (
    <div>
      <p>是否同意</p>
      <MySwitch
        value={field.value} // 当前值
        onChange={field.onChange} // 值变化回传
      />
      {fieldState.error && <p style={{ color: "red" }}>{fieldState.error.message}</p>} {/* 错误 */}
    </div>
  );
}

export default function DemoUseController() { // 页面组件
  const { control, handleSubmit } = useForm({
    defaultValues: { agree: false }, // 默认值
  });

  const onSubmit = (data) => console.log(data); // 提交回调

  return (
    <form onSubmit={handleSubmit(onSubmit)}> {/* 绑定提交 */}
      <AgreeField control={control} /> {/* 使用封装字段 */}
      <button type="submit">提交</button>
    </form>
  );
}

3)差异:

  • Controller:\ 你在 JSX 里写 <Controller render={...} />,\ field 在 render 里面拿
  • useController:\ 你在组件顶部 const { field } = useController(...),\ field 先拿出来,再自由渲染 JSX

4)使用场景:

  • 页面里就一两个特殊组件,图省事:**Controller**
  • 要封装通用组件(FormSwitch/FormDatePicker):**useController**

q.使用useController封装通用组件

1)封装FormSwitch.tsx

jsx 复制代码
import {
  Control, // 表单 control 类型
  FieldValues, // 表单数据的通用约束类型
  Path, // 字段路径类型(保证 name 合法)
  PathValue, // 字段路径对应的值类型
  RegisterOptions, // rules 类型
  useController, // 核心 hook
} from "react-hook-form"; // 引入 RHF 类型和 hook

type FormSwitchProps<T extends FieldValues> = {
  control: Control<T>; // 来自 useForm 的 control,且和表单 T 绑定
  name: Path<T>; // 字段名,受 T 约束
  label?: string; // 标签(可选)
  defaultValue?: boolean; // 默认值(布尔)
  rules?: RegisterOptions<T, Path<T>>; // 校验规则
  disabled?: boolean; // 是否禁用
};

export function FormSwitch<T extends FieldValues>({
  control,
  name,
  label,
  defaultValue = false,
  rules,
  disabled = false,
}: FormSwitchProps<T>) {
  const { field, fieldState } = useController({
    control, // 绑定表单 control
    name, // 绑定字段名
    defaultValue: defaultValue as PathValue<T, Path<T>>, // 给默认值(PathValue 保证类型安全)
    rules, // 绑定校验规则
  });

  return (
    <div style={{ marginBottom: 12 }}>
      {label && <label>{label}</label>}
      <br />
      <button
        type="button" // 避免触发表单提交
        disabled={disabled}
        onClick={() => field.onChange(!field.value)} // 切换 true/false
        onBlur={field.onBlur} // 通知 RHF:字段失焦
        style={{
          padding: "6px 12px",
          background: field.value ? "green" : "gray",
          color: "#fff",
          border: "none",
          borderRadius: 6,
          cursor: disabled ? "not-allowed" : "pointer",
        }}
      >
        {field.value ? "已开启" : "已关闭"}
      </button>

      {fieldState.error && (
        <p style={{ color: "red", margin: "6px 0 0" }}>
          {fieldState.error.message}
        </p>
      )}
    </div>
  );
}

2)封装FormDatePicker.tsx

jsx 复制代码
type FormDatePickerProps<T extends FieldValues> = {
  control: Control<T>; // useForm 返回的 control
  name: Path<T>; // 字段名(类型安全)
  label?: string; // 标签
  defaultValue?: string; // 默认值(yyyy-mm-dd)
  rules?: RegisterOptions<T, Path<T>>; // 校验规则
  disabled?: boolean; // 是否禁用
};

export function FormDatePicker<T extends FieldValues>({
  control,
  name,
  label,
  defaultValue = "",
  rules,
  disabled = false,
}: FormDatePickerProps<T>) {
  const { field, fieldState } = useController({
    control, // 绑定 control
    name, // 绑定字段
    defaultValue: defaultValue as PathValue<T, Path<T>>, // 默认值(PathValue 保证类型安全)
    rules, // 校验规则
  });

  return (
    <div style={{ marginBottom: 12 }}>
      {label && <label>{label}</label>}
      <br />
      <input
        type="date" // 原生日期选择
        value={(field.value ?? "") as string} // 防止 undefined
        onChange={(e) => field.onChange(e.target.value)} // 把字符串传回 RHF
        onBlur={field.onBlur} // 失焦同步
        disabled={disabled}
      />

      {fieldState.error && (
        <p style={{ color: "red", margin: "6px 0 0" }}>
          {fieldState.error.message}
        </p>
      )}
    </div>
  );
}

3)使用

jsx 复制代码
import { useForm } from "react-hook-form"; // 引入 useForm

type FormValues = {
  enabled: boolean; // 开关字段
  birthday: string; // 日期字段(yyyy-mm-dd)
};

export default function DemoForm() {
  const { control, handleSubmit } = useForm<FormValues>({
    defaultValues: {
      enabled: false, // 默认关闭
      birthday: "", // 默认空日期
    },
    mode: "onBlur",
  });

  const onSubmit = (data: FormValues) => {
    console.log("提交数据:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h3>TS 通用组件示例</h3>

      <FormSwitch<FormValues>
        control={control}
        name="enabled"
        label="是否启用"
        rules={{ required: "请确认是否启用" }}
      />

      <FormDatePicker<FormValues>
        control={control}
        name="birthday"
        label="生日"
        rules={{ required: "请选择生日" }}
      />

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

r.使用FormProvider + useFormContext一起封装组件

1)"旧方式"的痛点

旧方式你会这样写:

jsx 复制代码
<FormSwitch control={control} name="enabled" />
<FormDatePicker control={control} name="birthday" />

组件越多,你就越要不停传 control。\ 如果中间隔了 2~3 层子组件,会更麻烦(这叫"props drilling",你可以理解为"接力传参")。

2)新方式核心思路(就两步)

第 1 步:在最外层包上 FormProvider

把 useForm() 拿到的方法整体塞进去。

第 2 步:在子组件里用 useFormContext()

子组件自己拿 control,不再让父组件传。

3)封装

FormSwitch.tsx

jsx 复制代码
import {
  FieldValues, // 表单值类型约束
  Path, // name 的路径类型
  PathValue, // 字段路径对应的值类型
  useController, // 绑定字段
  useFormContext, // 从 Provider 拿表单上下文
} from "react-hook-form"; // 引入 RHF

type FormSwitchProps<T extends FieldValues> = {
  name: Path<T>; // 字段名
  label?: string; // 标签
};

export function FormSwitch<T extends FieldValues>({
  name,
  label,
}: FormSwitchProps<T>) {
  const { control } = useFormContext<T>(); // 直接从上下文拿 control(关键)

  const { field } = useController({
    control, // 绑定 control
    name, // 绑定字段
    defaultValue: false as PathValue<T, Path<T>>, // 默认值(PathValue 保证类型安全)
  });

  return (
    <div style={{ marginBottom: 12 }}>
      {label && <label>{label}</label>}
      <br />
      <button
        type="button" // 避免触发表单提交
        onClick={() => field.onChange(!field.value)} // 切换 true/false
      >
        {field.value ? "已开启" : "已关闭"}
      </button>
    </div>
  );
}

FormDatePicker.tsx

jsx 复制代码
type FormDatePickerProps<T extends FieldValues> = {
  name: Path<T>; // 字段名
  label?: string; // 标签
};

export function FormDatePicker<T extends FieldValues>({
  name,
  label,
}: FormDatePickerProps<T>) {
  const { control } = useFormContext<T>(); // 直接拿 control(关键)

  const { field } = useController({
    control, // 绑定 control
    name, // 绑定字段
    defaultValue: "" as PathValue<T, Path<T>>, // 默认值(PathValue 保证类型安全)
  });

  return (
    <div style={{ marginBottom: 12 }}>
      {label && <label>{label}</label>}
      <br />
      <input
        type="date" // 日期输入
        value={(field.value ?? "") as string} // 防止 undefined
        onChange={(e) => field.onChange(e.target.value)} // 回填表单
      />
    </div>
  );
}

使用:

jsx 复制代码
import { FormProvider, useForm } from "react-hook-form"; // 引入 RHF

type FormValues = {
  enabled: boolean; // 开关值
  birthday: string; // 日期值
};

export default function DemoForm() {
  const methods = useForm<FormValues>({
    defaultValues: {
      enabled: false, // 默认关闭
      birthday: "", // 默认空
    },
  });

  const onSubmit = (data: FormValues) => {
    console.log("提交数据:", data); // 查看提交结果
  };

  return (
    <FormProvider {...methods}>
      {/* 把整个表单能力放进上下文 */}
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <h3>FormProvider + useFormContext 示例</h3>

        {/* 注意:这里不再传 control 了 */}
        <FormSwitch<FormValues> name="enabled" label="是否启用" />
        <FormDatePicker<FormValues> name="birthday" label="生日" />

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

4)总结:

FormProvider 负责"提供",useFormContext 负责"取用"。

s.AntD 对接 React Hook Form

1)封装预期:

  1. FormSwitch.tsx(封装 AntD Switch)
  2. FormSelect.tsx(封装 AntD Select)
  3. FormDatePicker.tsx(封装 AntD DatePicker)
  4. UserForm.tsx(页面:FormProvider + 提交转换)

2)封装

jsx 复制代码
import { Switch } from "antd"; // 引入 AntD 的 Switch
import { FieldValues, Path, useController, useFormContext } from "react-hook-form"; // 引入 RHF 相关能力

type FormSwitchProps<T extends FieldValues> = {
  name: Path<T>; // 字段名(受 TS 约束)
  label?: string; // 标签文案
};

export function FormSwitch<T extends FieldValues>({ name, label }: FormSwitchProps<T>) {
  const { control } = useFormContext<T>(); // 从 FormProvider 上下文中拿 control

  const { field, fieldState } = useController({
    name, // 绑定字段名
    control, // 绑定 control
  });

  return (
    <div style={{ marginBottom: 16 }}>
      {label && <div style={{ marginBottom: 8 }}>{label}</div>}
      <Switch
        checked={!!field.value} // Switch 用 checked,不是 value
        onChange={(checked) => field.onChange(checked)} // onChange 直接给 boolean
      />
      {fieldState.error && (
        <div style={{ color: "red", marginTop: 6 }}>{fieldState.error.message}</div>
      )}
    </div>
  );
}
jsx 复制代码
import { Select } from "antd"; // 引入 AntD Select

type Option = {
  label: string; // 选项显示文本
  value: string; // 选项值
};

type FormSelectProps<T extends FieldValues> = {
  name: Path<T>; // 字段名
  label?: string; // 标签
  options: Option[]; // 选项列表
  mode?: "multiple"; // 是否多选
  placeholder?: string; // 占位符
};

export function FormSelect<T extends FieldValues>({
  name,
  label,
  options,
  mode,
  placeholder,
}: FormSelectProps<T>) {
  const { control } = useFormContext<T>(); // 从上下文拿 control

  const { field, fieldState } = useController({
    name, // 字段名
    control, // control
  });

  return (
    <div style={{ marginBottom: 16 }}>
      {label && <div style={{ marginBottom: 8 }}>{label}</div>}
      <Select
        style={{ width: 320 }} // 给个宽度
        mode={mode} // 多选模式
        options={options} // 选项
        value={field.value} // 当前值(单选 string / 多选 string[])
        onChange={(val) => field.onChange(val)} // Select onChange 直接给值
        onBlur={field.onBlur} // blur
        placeholder={placeholder} // 占位
      />
      {fieldState.error && (
        <div style={{ color: "red", marginTop: 6 }}>{fieldState.error.message}</div>
      )}
    </div>
  );
}
jsx 复制代码
import { DatePicker } from "antd"; // 引入 AntD DatePicker
import dayjs, { Dayjs } from "dayjs"; // 引入 dayjs

type FormDatePickerProps<T extends FieldValues> = {
  name: Path<T>; // 字段名
  label?: string; // 标签
  placeholder?: string; // 占位符
  format?: string; // 日期格式
};

export function FormDatePicker<T extends FieldValues>({
  name,
  label,
  placeholder,
  format = "YYYY-MM-DD",
}: FormDatePickerProps<T>) {
  const { control } = useFormContext<T>(); // 从上下文拿 control

  const { field, fieldState } = useController({
    name, // 字段名
    control, // control
  });

  return (
    <div style={{ marginBottom: 16 }}>
      {label && <div style={{ marginBottom: 8 }}>{label}</div>}
      <DatePicker
        format={format} // 显示格式
        placeholder={placeholder} // 占位
        value={field.value ? dayjs(field.value, format) : null} // RHF 里存 string,这里转成 dayjs
        onChange={(date: Dayjs | null) => {
          field.onChange(date ? date.format(format) : ""); // 选中后再转回 string 存入表单
        }}
        onBlur={field.onBlur} // blur
      />
      {fieldState.error && (
        <div style={{ color: "red", marginTop: 6 }}>{fieldState.error.message}</div>
      )}
    </div>
  );
}
jsx 复制代码
import { Button } from "antd"; // 引入 AntD Button
import { FormProvider, useForm } from "react-hook-form"; // 引入 RHF

type UserFormValues = {
  enabled: boolean; // 开关
  hobbies: string[]; // 多选爱好
  birthday: string; // 生日(字符串,便于传后端)
};

export default function UserForm() {
  const methods = useForm<UserFormValues>({
    defaultValues: {
      enabled: true, // 默认开启
      hobbies: [], // 默认空数组
      birthday: "", // 默认空字符串
    },
    mode: "onBlur", // 失焦触发校验(示例配置)
  });

  const onSubmit = (values: UserFormValues) => {
    // 这里演示"提交前统一转换"
    const payload = {
      ...values, // 先展开原值
      enabled: values.enabled ? 1 : 0, // 比如后端要 1/0
      birthday: values.birthday || null, // 空字符串转 null
    };

    console.log("表单原始值:", values); // 看 RHF 内部值
    console.log("提交给后端:", payload); // 看最终提交值
  };

  return (
    <FormProvider {...methods}>
      {/* 提供表单上下文,子组件可 useFormContext 获取 control */}
      <form onSubmit={methods.handleSubmit(onSubmit)} style={{ padding: 24 }}>
        <h2>AntD + RHF 示例</h2>

        <FormSwitch<UserFormValues> name="enabled" label="是否启用" />

        <FormSelect<UserFormValues>
          name="hobbies"
          label="爱好(可多选)"
          mode="multiple"
          placeholder="请选择爱好"
          options={[
            { label: "篮球", value: "basketball" },
            { label: "音乐", value: "music" },
            { label: "旅行", value: "travel" },
          ]}
        />

        <FormDatePicker<UserFormValues>
          name="birthday"
          label="生日"
          placeholder="请选择生日"
          format="YYYY-MM-DD"
        />

        <Button type="primary" htmlType="submit">
          提交
        </Button>
      </form>
    </FormProvider>
  );
}

3)坑点:

  1. Switch 用错属性
    • 错误:value={field.value}
    • 正确:checked={field.value}
  2. DatePicker 值类型不匹配
    • AntD 要 dayjs | null
    • 你表单里常存 string
    • 所以要"来回转换"
  3. 默认值漏写
    • 多选 Select 不给 [],容易从受控变非受控警告
  4. 忘记 FormProvider
    • useFormContext 会直接报错
  5. 提交前不做格式转换
    • 前端内部值适合交互,不一定是后端最终想要的格式

t.项目实际使用:HOC转换,封装通用组件(基础理解版)

1)封装FormContainer

jsx 复制代码
import { useRef } from "react";
import { useForm,FormProvider,UseFormReturn } from "react-hook-form";

/**
 * 创建一个 ref,用于在组件外部访问 form 实例(如手动调用 reset、setValue 等方法)。
 * 泛型 Values 约束表单数据的类型结构,默认为任意键值对。
 */
export const useFormRef = <
  Values extends Record<string, any> = Record<string, any>,
>() => {
  return useRef<UseFormReturn<Values> | null>(null);
};

interface FormProps {
  defaultValues: any;
  /** 表单校验通过后的提交回调 */
  onFinish: (data: any) => void;
  /** 表单校验失败后的回调(可选) */
  onFinishFailed?: (errors: any) => void;
  children: React.ReactNode;
  /** 外部 ref,挂载后可通过 formRef.current 调用 react-hook-form 的所有方法 */
  formRef?: React.RefObject<UseFormReturn<any> | null>;
}

export function Form(props: FormProps) {
  const {defaultValues,onFinish,onFinishFailed,children,formRef} = props;

  // useForm 是 react-hook-form 的核心 hook,返回表单控制对象(包含 register、control、handleSubmit 等)
  const form = useForm({defaultValues});

  // 将 form 实例同步到外部 ref,使父组件可以直接调用 form 方法(如 reset)
  if(formRef){
    formRef.current = form;
  }

  // FormProvider 通过 React Context 将 form 实例共享给所有子组件
  // 子组件可通过 useFormContext() 读取,无需层层传 prop
  return <FormProvider {...form}>
    {/* handleSubmit 会先执行校验,通过后调用 onFinish,失败则调用 onFinishFailed */}
    <form onSubmit={form.handleSubmit(onFinish,onFinishFailed)}>
      {children}
    </form>
  </FormProvider>
}

2)封装FormItem样式

jsx 复制代码
/**
 * FormItem ------ 纯 UI 展示组件,负责渲染表单项的三层结构:
 *   1. 标签行(label + 必填星号)
 *   2. 输入控件(children,由外部传入)
 *   3. 错误提示(来自 react-hook-form 的校验信息)
 *
 * 它本身不感知 react-hook-form,只是一个"外壳"容器。
 */
export interface FormItemProps {
  label?: React.ReactNode
  required?: boolean
  /** 校验失败时的错误文案,由 WithFormItem 从 fieldState.error.message 注入 */
  error?: string
  children: React.ReactNode
}

export function FormItem({label,required,error,children}: FormItemProps) {
  return (
    <div>
        {/* 标签行:required 为 true 时显示红色星号提示必填 */}
        <div>{label}{required ? <span>*</span> : null}</div>
        {/* 实际的输入控件插槽 */}
        <div>{children}</div>
        {/* 错误信息行:error 有值时才渲染,避免占用空白空间 */}
        <div style={{ color: 'red' }}>{error ? <span>{error}</span> : null}</div>
    </div>
  )
}

3)封装withFormItem HOC高阶组件

jsx 复制代码
import { FormItem, type FormItemProps } from "./FormItem";
import { useController, useFormContext} from 'react-hook-form'

/** react-hook-form 字段必须具备的 props */
interface RHFFieldProps {
  /** 字段在表单数据对象中的 key,对应 defaultValues 的属性名 */
  name: string;
  /** 校验规则,同 react-hook-form 的 RegisterOptions(required / minLength / pattern 等) */
  rules?: Record<string, any>;
  defaultValue?: unknown;
}

/**
 * WithFormItem ------ 高阶组件(HOC)
 *
 * 作用:把任意 UI 输入组件(如 antd 的 Input、Select)接入 react-hook-form 的状态管理体系。
 *
 * 工作流程:
 *   1. 通过 useFormContext() 从 FormProvider 提供的 Context 中取出 control 对象。
 *   2. 通过 useController() 用 name + control + rules 注册字段,
 *      返回 field(受控绑定)和 fieldState(校验状态)。
 *   3. 将 field.value / onChange / onBlur 注入被包裹的 Component,使其变为受控组件。
 *   4. 将 fieldState.error.message 传给 FormItem,用于显示错误提示。
 *
 * 泛型 P:被包裹组件自身的 props 类型,确保透传时类型安全。
 * 最终组件的 props = 组件原有 props(P) + FormItem 展示 props + RHF 字段 props
 */
export function WithFormItem<P extends Record<string, any>>(Component: React.ComponentType<P>) {

  return function New(props: P & Omit<FormItemProps, 'children' | 'error'> & RHFFieldProps) {
    // 从最近的 FormProvider Context 中取出 control,无需手动传递
    const {control} = useFormContext()

    // 拆分出 RHF 专属 props,剩余的 rest 会透传给 FormItem(如 label、required、style 等)
    const {name,rules,defaultValue,...rest} = props

    // useController 将字段注册到 react-hook-form,
    // field  ------ 包含 value / onChange / onBlur / ref,用于绑定到输入控件
    // fieldState ------ 包含 error / isDirty / isTouched 等状态
    const {field,fieldState} = useController({
        name,
        control,
        rules,
        defaultValue
    })

    return (
      <div>
        {/* error 来自 RHF 校验结果,label/required 等来自 rest */}
        <FormItem error={fieldState.error?.message} {...rest}>
          {/* 用 field 的受控属性覆盖组件原有的 value/onChange/onBlur,
              使组件的输入变化能同步到 react-hook-form 的内部状态 */}
          <Component {...props} value={field.value} onChange={field.onChange} onBlur={field.onBlur} />
        </FormItem>
      </div>
    );
  };
}

4)使用withFormItem进行包装第三方ui组件

jsx 复制代码
import { Input, Select, DatePicker, Switch} from 'antd';
// import dayjs, { type Dayjs } from 'dayjs';
import { WithFormItem } from './WithFormItem';

/**
 * 使用 WithFormItem HOC 将 antd 原生组件包裹为 react-hook-form 受控字段。
 * 包裹后的组件同时具备:
 *   - antd 组件原有的所有 props(如 placeholder、size、options 等)
 *   - FormItem 的展示 props(label、required)
 *   - RHF 的字段 props(name、rules、defaultValue)
 */
export const RHFInput = WithFormItem(Input);
export const RHFSelect = WithFormItem(Select);
export const RHFDatePicker = WithFormItem(DatePicker);
export const RHFSwitch = WithFormItem(Switch);

5)使用示例

jsx 复制代码
'use client';
import { Button } from 'antd';
import { Form, useFormRef } from './Form';
import { RHFInput, RHFSelect, RHFDatePicker,RHFSwitch } from './Fields';

/**
 * DemoTest ------ 整个表单体系的使用示例
 *
 * 组件层次关系:
 *   Form (提供 FormProvider Context)
 *     └─ RHFInput / RHFSelect / ... (WithFormItem 包裹后的受控字段)
 *           ├─ FormItem (渲染 label + 错误信息)
 *           └─ antd 原生组件 (受 field.value/onChange 驱动)
 */
export function DemoTest(){
    const onFinish = (data: any) => {
        // 校验通过后,data 包含所有字段的最新值
        console.log(data)
    }

    // useFormRef 返回一个 ref,Form 会在渲染时将 form 实例写入 ref.current
    // 从而在组件外部(如按钮回调)也能调用 reset / setValue / getValues 等方法
    const formRef = useFormRef();

    const cancelFunction = () => {
        console.log('formRef.current',formRef.current);
        // 通过 ref 直接调用 react-hook-form 的 reset,将所有字段恢复为 defaultValues
        formRef.current?.reset();
    }

    return (
        // defaultValues 为各字段的初始值,onFinish 为提交成功回调
        <Form formRef={formRef} defaultValues={{ username: '123', hobby: 'basketball', birthday: '', switch: true }} onFinish={onFinish}>
            {/* name 对应 defaultValues 的 key;rules 为校验规则,失败时错误信息显示在字段下方 */}
            <RHFInput name="username" label="用户名" rules={{ required: '请输入用户名' }} value="123"/>
            <RHFSelect name="hobby" label="爱好" rules={{ required: '请选择爱好' }} value="basketball"
              options={[
                { label: '篮球', value: 'basketball' },
                { label: '足球', value: 'football' },
                { label: '排球', value: 'volleyball' },
            ]}
            style={{ width: '100px' }}
            />
            <RHFDatePicker name="birthday" label="生日" rules={{ required: '请选择生日' }} />
            <RHFSwitch name="switch" label="开关"  checked={true}/>
            {/* type="default" 普通按钮,手动触发重置 */}
            <Button type="default" onClick={cancelFunction}>取消</Button>
            {/* htmlType="submit" 触发原生 form 提交,进而执行 handleSubmit → onFinish */}
            <Button type="primary" htmlType="submit">提交</Button>
        </Form>
    )
}

三.示例Demo地址

地址:gitee.com/rui-rui-an/...

相关推荐
道里2 小时前
花了 5 万刀用 AI 写代码之后,这是我的全部经验
前端·人工智能
Royzst3 小时前
xml知识点
java·服务器·前端
IT_陈寒3 小时前
React useEffect闭包陷阱差点把我整失业了
前端·人工智能·后端
kyriewen4 小时前
推行AI写代码一年后,Code Review变成了新的加班理由
前端·ai编程·cursor
前端环境观察室4 小时前
给 Agent Browser Workflow 加一层可观测性:Trace、Snapshot 和 Review Queue
前端
柒瑞4 小时前
Superpowers结合Claude code浅实战
前端
Nian.Baikal5 小时前
从零搭建离线地图服务:Nginx + Cesium/Leaflet 实战指南
运维·前端·nginx
前端毕业班5 小时前
uniapp web 灵活控制 style scoped
前端·javascript·vue.js
lichenyang4535 小时前
鸿蒙业务需求实战:AI 问题走马灯卡片实现复盘
前端
ZTStory5 小时前
mise 一款可以在项目中独立管理语言、环境变量和任务的工具
前端·rust·命令行