- 手风琴
- 提示
- 警告对话框
- 宽高比
- 头像
- 徽章
- 面包屑导航
- 按钮
- 按钮组
- 日历 Calendar
- 卡片
- Carousel
- 图表 Chart
- 复选框
- 折叠面板
- 组合框
- 命令
- 上下文菜单
- 数据表格 Data Table
- 日期选择器 Date Picker
- 对话框 Dialog
- 抽屉
- 下拉菜单
- Empty
- 字段
- 悬停卡片
- 输入
- 输入组
- 输入 OTP
- 项目
- Kbd
- 标签
- 菜单栏
- 原生选择框
- 导航菜单 Navigation Menu
- 分页
- 弹出框
- 进度 Progress
- 单选框组
- 可调整大小
- 滚动区域 Scroll Area
- 选择框
- 分隔符 Separator
- 侧边栏 Sheet
- 侧边栏 Sidebar
- 骨架屏
- 滑块
- Sonner
- 加载指示器 Spinner
- 开关
- 表格
- 标签页 Tabs
- 文本域
- 吐司
- 切换按钮 Toggle
- 切换组
- 提示 Tooltip
- 排版
在本指南中,我们将了解如何使用 React Hook Form 构建表单。内容包括使用 <Field /> 组件构建表单、使用 Zod 添加模式验证、错误处理、无障碍支持等。
演示
我们将构建如下表单。它包含一个简单的文本输入框和一个多行文本框。提交时,我们将验证表单数据并显示任何错误。
注意: 为了演示目的,我们故意禁用了浏览器自带的验证,以展示在 React Hook Form 中模式验证和表单错误的工作原理。建议在生产代码中添加基本的浏览器验证。
"use client"
import * as React from "react"方法
该表单利用 React Hook Form 实现高性能且灵活的表单处理。我们将使用 <Field /> 组件构建表单,该组件赋予您对标记和样式的完全灵活性。
- 使用 React Hook Form 的
useForm钩子管理表单状态。 - 使用
<Controller />组件管理受控输入。 - 使用
<Field />组件构建无障碍表单。 - 使用 Zod 和
zodResolver进行客户端验证。
结构
下面是一个使用 React Hook Form 的 <Controller /> 组件和 <Field /> 组件构建表单的基本示例。
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>错误标题</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
placeholder="登录按钮在移动端无法使用"
autoComplete="off"
/>
<FieldDescription>
请为您的错误报告提供简明的标题。
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>表单
创建表单模式
首先,我们使用 Zod 模式定义表单结构。
注意: 本示例使用 zod v3 进行模式验证,但您可以使用 React Hook Form 支持的任何其他标准模式验证库替代。
import * as z from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "错误标题至少需要5个字符。")
.max(32, "错误标题最多32个字符。"),
description: z
.string()
.min(20, "描述至少需要20个字符。")
.max(100, "描述最多100个字符。"),
})设置表单
接下来,我们将使用 React Hook Form 中的 useForm 钩子创建表单实例,并添加 Zod 解析器用于数据验证。
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "错误标题至少需要5个字符。")
.max(32, "错误标题最多32个字符。"),
description: z
.string()
.min(20, "描述至少需要20个字符。")
.max(100, "描述最多100个字符。"),
})
export function BugReportForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
// 对表单数值进行处理
console.log(data)
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* ... */}
{/* 在此处构建表单 */}
{/* ... */}
</form>
)
}构建表单
现在我们可以使用 React Hook Form 的 <Controller /> 组件和 <Field /> 组件构建表单。
"use client"
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
InputGroup,
InputGroupAddon,
InputGroupText,
InputGroupTextarea,
} from "@/components/ui/input-group"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})
export function BugReportForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
toast("You submitted the following values:", {
description: (
<pre className="bg-code text-code-foreground mt-2 w-[320px] overflow-x-auto rounded-md p-4">
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
),
position: "bottom-right",
classNames: {
content: "flex flex-col gap-2",
},
style: {
"--border-radius": "calc(var(--radius) + 4px)",
} as React.CSSProperties,
})
}
return (
<Card className="w-full sm:max-w-md">
<CardHeader>
<CardTitle>Bug Report</CardTitle>
<CardDescription>
Help us improve by reporting bugs you encounter.
</CardDescription>
</CardHeader>
<CardContent>
<form id="form-rhf-demo" onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-rhf-demo-title">
Bug Title
</FieldLabel>
<Input
{...field}
id="form-rhf-demo-title"
aria-invalid={fieldState.invalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-rhf-demo-description">
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id="form-rhf-demo-description"
placeholder="I'm having an issue with the login button on mobile."
rows={6}
className="min-h-24 resize-none"
aria-invalid={fieldState.invalid}
/>
<InputGroupAddon align="block-end">
<InputGroupText className="tabular-nums">
{field.value.length}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>
Include steps to reproduce, expected behavior, and what
actually happened.
</FieldDescription>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
</FieldGroup>
</form>
</CardContent>
<CardFooter>
<Field orientation="horizontal">
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit" form="form-rhf-demo">
Submit
</Button>
</Field>
</CardFooter>
</Card>
)
}
完成
就是这样。现在您拥有了一个带有客户端验证的完备无障碍表单。
提交表单时,onSubmit 函数将接收验证后的表单数据。如果数据无效,React Hook Form 会在各字段旁边显示错误信息。
验证
客户端验证
React Hook Form 使用 Zod 模式验证您的表单数据。定义一个模式并传递给 useForm 钩子的 resolver 选项。
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
const formSchema = z.object({
title: z.string(),
description: z.string().optional(),
})
export function ExampleForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
}验证模式
React Hook Form 支持多种验证模式。
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
})| 模式 | 描述 |
|---|---|
"onChange" | 每次更改时触发验证。 |
"onBlur" | 在失焦时触发验证。 |
"onSubmit" | 在提交时触发验证(默认)。 |
"onTouched" | 首次失焦时触发验证,之后每次更改时都触发。 |
"all" | 在失焦和更改时均触发验证。 |
显示错误
在字段旁使用 <FieldError /> 显示错误。为样式和无障碍支持:
- 在
<Field />组件上添加data-invalid属性。 - 在表单控件如
<Input />、<SelectTrigger />、<Checkbox />等添加aria-invalid属性。
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>邮箱</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>处理不同类型的字段
输入框(Input)
- 对于输入字段,将
field对象展开应用到<Input />组件。 - 显示错误时,向
<Input />组件添加aria-invalid属性,向<Field />组件添加data-invalid属性。
"use client"
import { zodResolver } from "@hookform/resolvers/zod"对于简单文本输入,将 field 对象展开应用于输入框。
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>姓名</FieldLabel>
<Input {...field} id={field.name} aria-invalid={fieldState.invalid} />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>多行文本框(Textarea)
- 对于多行文本,将
field对象展开应用到<Textarea />组件。 - 显示错误时,向
<Textarea />组件添加aria-invalid属性,向<Field />组件添加data-invalid属性。
"use client"
import * as React from "react"对于多行文本,将 field 对象展开应用到多行文本框。
<Controller
name="about"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-rhf-textarea-about">更多信息</FieldLabel>
<Textarea
{...field}
id="form-rhf-textarea-about"
aria-invalid={fieldState.invalid}
placeholder="我是一名软件工程师..."
className="min-h-[120px]"
/>
<FieldDescription>
告诉我们更多关于您的信息,这将帮助我们个性化您的体验。
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>下拉选择(Select)
- 对于选择组件,在
<Select />组件上使用field.value和field.onChange。 - 显示错误时,在
<SelectTrigger />组件上添加aria-invalid,在<Field />组件上添加data-invalid。
"use client"
import * as React from "react"<Controller
name="language"
control={form.control}
render={({ field, fieldState }) => (
<Field orientation="responsive" data-invalid={fieldState.invalid}>
<FieldContent>
<FieldLabel htmlFor="form-rhf-select-language">
使用语言
</FieldLabel>
<FieldDescription>
为获得最佳效果,请选择您所使用的语言。
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldContent>
<Select
name={field.name}
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger
id="form-rhf-select-language"
aria-invalid={fieldState.invalid}
className="min-w-[120px]"
>
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto">自动</SelectItem>
<SelectItem value="en">英语</SelectItem>
</SelectContent>
</Select>
</Field>
)}
/>复选框(Checkbox)
- 对于复选框数组,使用
field.value和field.onChange,并操作数组。 - 显示错误时,向
<Checkbox />组件添加aria-invalid,向<Field />组件添加data-invalid。 - 记得在
<FieldGroup />组件上添加data-slot="checkbox-group"以获得正确的样式和间距。
"use client"
import * as React from "react"<Controller
name="tasks"
control={form.control}
render={({ field, fieldState }) => (
<FieldSet>
<FieldLegend variant="label">任务</FieldLegend>
<FieldDescription>
当您创建的任务有更新时,将通知您。
</FieldDescription>
<FieldGroup data-slot="checkbox-group">
{tasks.map((task) => (
<Field
key={task.id}
orientation="horizontal"
data-invalid={fieldState.invalid}
>
<Checkbox
id={`form-rhf-checkbox-${task.id}`}
name={field.name}
aria-invalid={fieldState.invalid}
checked={field.value.includes(task.id)}
onCheckedChange={(checked) => {
const newValue = checked
? [...field.value, task.id]
: field.value.filter((value) => value !== task.id)
field.onChange(newValue)
}}
/>
<FieldLabel
htmlFor={`form-rhf-checkbox-${task.id}`}
className="font-normal"
>
{task.label}
</FieldLabel>
</Field>
))}
</FieldGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldSet>
)}
/>单选组(Radio Group)
- 对于单选组,在
<RadioGroup />组件上使用field.value和field.onChange。 - 显示错误时,向
<RadioGroupItem />组件添加aria-invalid,向<Field />组件添加data-invalid。
"use client"
import * as React from "react"<Controller
name="plan"
control={form.control}
render={({ field, fieldState }) => (
<FieldSet>
<FieldLegend>计划</FieldLegend>
<FieldDescription>
您可以随时升级或降级您的计划。
</FieldDescription>
<RadioGroup
name={field.name}
value={field.value}
onValueChange={field.onChange}
>
{plans.map((plan) => (
<FieldLabel key={plan.id} htmlFor={`form-rhf-radiogroup-${plan.id}`}>
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<FieldContent>
<FieldTitle>{plan.title}</FieldTitle>
<FieldDescription>{plan.description}</FieldDescription>
</FieldContent>
<RadioGroupItem
value={plan.id}
id={`form-rhf-radiogroup-${plan.id}`}
aria-invalid={fieldState.invalid}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldSet>
)}
/>开关(Switch)
- 对于开关,在
<Switch />组件上使用field.value和field.onChange。 - 显示错误时,向
<Switch />组件添加aria-invalid,向<Field />组件添加data-invalid。
"use client"
import * as React from "react"<Controller
name="twoFactor"
control={form.control}
render={({ field, fieldState }) => (
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<FieldContent>
<FieldLabel htmlFor="form-rhf-switch-twoFactor">
多因素认证
</FieldLabel>
<FieldDescription>
启用多因素认证以保护您的账户安全。
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldContent>
<Switch
id="form-rhf-switch-twoFactor"
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
aria-invalid={fieldState.invalid}
/>
</Field>
)}
/>复杂表单
这是一个包含多个字段和验证的复杂表单示例。
"use client"
import * as React from "react"重置表单
使用 form.reset() 重置表单到默认值。
<Button type="button" variant="outline" onClick={() => form.reset()}>
重置
</Button>数组字段
React Hook Form 提供了 useFieldArray 钩子来管理动态数组字段,适用于需要动态添加或删除字段的场景。
"use client"
import * as React from "react"使用 useFieldArray
使用 useFieldArray 来管理数组字段,它会返回 fields、append 和 remove 方法。
import { useFieldArray, useForm } from "react-hook-form"
export function ExampleForm() {
const form = useForm({
// ... 表单配置
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "emails",
})
}数组字段结构
将数组字段包裹在 <FieldSet /> 中,包含 <FieldLegend /> 和 <FieldDescription />。
<FieldSet className="gap-4">
<FieldLegend variant="label">邮箱地址</FieldLegend>
<FieldDescription>
添加最多5个我们可以联系您的邮箱地址。
</FieldDescription>
<FieldGroup className="gap-4">{/* 数组项放置处 */}</FieldGroup>
</FieldSet>数组项的 Controller 方式
遍历 fields 数组并对每项使用 <Controller />。务必使用 field.id 作为 key。
{
fields.map((field, index) => (
<Controller
key={field.id}
name={`emails.${index}.address`}
control={form.control}
render={({ field: controllerField, fieldState }) => (
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
<FieldContent>
<InputGroup>
<InputGroupInput
{...controllerField}
id={`form-rhf-array-email-${index}`}
aria-invalid={fieldState.invalid}
placeholder="name@example.com"
type="email"
autoComplete="email"
/>
{/* 删除按钮 */}
</InputGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldContent>
</Field>
)}
/>
))
}添加项
使用 append 方法向数组添加新项。
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ address: "" })}
disabled={fields.length >= 5}
>
添加邮箱地址
</Button>移除项
使用 remove 方法删除数组项,条件渲染删除按钮。
{
fields.length > 1 && (
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
onClick={() => remove(index)}
aria-label={`移除邮箱 ${index + 1}`}
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)
}数组验证
使用 Zod 的 array 方法来验证数组字段。
const formSchema = z.object({
emails: z
.array(
z.object({
address: z.string().email("请输入有效的邮箱地址。"),
})
)
.min(1, "请至少添加一个邮箱地址。")
.max(5, "最多可添加5个邮箱地址。"),
})