- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Avatar
- Badge
- Breadcrumb
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Input OTP
- Item
- Kbd
- Label
- Menubar
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toast
- Toggle
- Toggle Group
- Tooltip
- Typography
本指南将介绍如何使用 React Hook Form 构建表单。我们会讲到如何使用 <Field /> 组件构建表单、如何使用 Zod 添加 schema 校验、错误处理、可访问性等内容。
演示
我们将构建下面这个表单。它包含一个简单的文本输入框和一个文本域。提交时,我们会校验表单数据并显示任何错误。
注意: 为了演示效果,我们故意禁用了浏览器校验,以展示在 React Hook Form 中 schema 校验和表单错误是如何工作的。建议你在生产代码中添加基础的浏览器校验。
"use client"
import * as React from "react"思路
这个表单利用 React Hook Form 提供高性能且灵活的表单处理能力。我们会使用 <Field /> 组件来构建表单,它让你对 标记和样式拥有完全的灵活性。
- 使用 React Hook Form 的
useFormhook 来管理表单状态。 - 使用
<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}>Bug Title</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
<FieldDescription>
Provide a concise title for your bug report.
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>表单
创建表单 schema
首先,我们使用 Zod schema 来定义表单的结构。
注意: 这个示例使用 zod v3 做 schema 校验,不过你也可以把它替换成 React Hook Form 支持的其他 Standard Schema 校验库。
import * as z from "zod"
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."),
})配置表单
接下来,我们使用 React Hook Form 的 useForm hook 创建表单实例。同时加入 Zod 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()
.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>) {
// 对表单值做一些处理。
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="mt-2 w-[320px] overflow-x-auto rounded-md bg-code p-4 text-code-foreground">
<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 schema 校验你的表单数据。定义一个 schema,并把它传给 useForm hook 的 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: "",
},
})
}Validation Modes
React Hook Form supports different validation modes.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
})| Mode | Description |
|---|---|
"onChange" | Validation triggers on every change. |
"onBlur" | Validation triggers on blur. |
"onSubmit" | Validation triggers on submit (default). |
"onTouched" | Validation triggers on first blur, then on every change. |
"all" | Validation triggers on blur and change. |
Displaying Errors
Display errors next to the field using <FieldError />. For styling and accessibility:
- Add the
data-invalidprop to the<Field />component. - Add the
aria-invalidprop to the form control such as<Input />,<SelectTrigger />,<Checkbox />, etc.
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Working with Different Field Types
Input
- For input fields, spread the
fieldobject onto the<Input />component. - To show errors, add the
aria-invalidprop to the<Input />component and thedata-invalidprop to the<Field />component.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"For simple text inputs, spread the field object onto the input.
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input {...field} id={field.name} aria-invalid={fieldState.invalid} />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Textarea
- For textarea fields, spread the
fieldobject onto the<Textarea />component. - To show errors, add the
aria-invalidprop to the<Textarea />component and thedata-invalidprop to the<Field />component.
"use client"
import * as React from "react"For textarea fields, spread the field object onto the textarea.
<Controller
name="about"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="form-rhf-textarea-about">More about you</FieldLabel>
<Textarea
{...field}
id="form-rhf-textarea-about"
aria-invalid={fieldState.invalid}
placeholder="I'm a software engineer..."
className="min-h-30"
/>
<FieldDescription>
Tell us more about yourself. This will be used to help us personalize
your experience.
</FieldDescription>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Select
- For select components, use
field.valueandfield.onChangeon the<Select />component. - To show errors, add the
aria-invalidprop to the<SelectTrigger />component and thedata-invalidprop to the<Field />component.
"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">
Spoken Language
</FieldLabel>
<FieldDescription>
For best results, select the language you speak.
</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-30"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</Field>
)}
/>Checkbox
- For checkbox arrays, use
field.valueandfield.onChangewith array manipulation. - To show errors, add the
aria-invalidprop to the<Checkbox />component and thedata-invalidprop to the<Field />component. - Remember to add
data-slot="checkbox-group"to the<FieldGroup />component for proper styling and spacing.
"use client"
import * as React from "react"<Controller
name="tasks"
control={form.control}
render={({ field, fieldState }) => (
<FieldSet>
<FieldLegend variant="label">Tasks</FieldLegend>
<FieldDescription>
Get notified when tasks you've created have updates.
</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
- For radio groups, use
field.valueandfield.onChangeon the<RadioGroup />component. - To show errors, add the
aria-invalidprop to the<RadioGroupItem />component and thedata-invalidprop to the<Field />component.
"use client"
import * as React from "react"<Controller
name="plan"
control={form.control}
render={({ field, fieldState }) => (
<FieldSet>
<FieldLegend>Plan</FieldLegend>
<FieldDescription>
You can upgrade or downgrade your plan at any time.
</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
- For switches, use
field.valueandfield.onChangeon the<Switch />component. - To show errors, add the
aria-invalidprop to the<Switch />component and thedata-invalidprop to the<Field />component.
"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">
Multi-factor authentication
</FieldLabel>
<FieldDescription>
Enable multi-factor authentication to secure your account.
</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>
)}
/>Complex Forms
Here is an example of a more complex form with multiple fields and validation.
"use client"
import * as React from "react"Resetting the Form
Use form.reset() to reset the form to its default values.
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>Array Fields
React Hook Form provides a useFieldArray hook for managing dynamic array fields. This is useful when you need to add or remove fields dynamically.
"use client"
import * as React from "react"Using useFieldArray
Use the useFieldArray hook to manage array fields. It provides fields, append, and remove methods.
import { useFieldArray, useForm } from "react-hook-form"
export function ExampleForm() {
const form = useForm({
// ... form config
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "emails",
})
}Array Field Structure
Wrap your array fields in a <FieldSet /> with a <FieldLegend /> and <FieldDescription />.
<FieldSet className="gap-4">
<FieldLegend variant="label">Email Addresses</FieldLegend>
<FieldDescription>
Add up to 5 email addresses where we can contact you.
</FieldDescription>
<FieldGroup className="gap-4">{/* Array items go here */}</FieldGroup>
</FieldSet>Controller Pattern for Array Items
Map over the fields array and use <Controller /> for each item. Make sure to use field.id as the 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"
/>
{/* Remove button */}
</InputGroup>
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</FieldContent>
</Field>
)}
/>
))
}Adding Items
校验模式
Use the append method to add new items to the array.
React Hook Form 支持不同的校验模式。
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ address: "" })}
disabled={fields.length >= 5}
>
| 模式 | 描述 |
| ------------- | -------------------------------------------------------- |
| `"onChange"` | 每次变化时触发校验。 |
| `"onBlur"` | 失焦时触发校验。 |
| `"onSubmit"` | 提交时触发校验(默认)。 |
| `"onTouched"` | 第一次失焦后触发,之后每次变化都触发。 |
| `"all"` | 失焦和变化时都触发校验。 |
Add Email Address
</Button>使用 <FieldError /> 在字段旁边显示错误。为了样式和可访问性:
- 给
<Field />组件添加data-invalid属性。 - 给表单控件添加
aria-invalid属性,例如<Input />、<SelectTrigger />、<Checkbox />等。
{
fields.length > 1 && (
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
variant="ghost"
- 对于输入框字段,把 `field` 对象展开到 `<Input />` 组件上。
- 要显示错误,请给 `<Input />` 组件添加 `aria-invalid` 属性,并给 `<Field />` 组件添加 `data-invalid` 属性。
aria-label={`Remove email ${index + 1}`}
对于简单文本输入框,把 `field` 对象展开到输入框上。
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)
- 对于文本域字段,把 `field` 对象展开到 `<Textarea />` 组件上。
- 要显示错误,请给 `<Textarea />` 组件添加 `aria-invalid` 属性,并给 `<Field />` 组件添加 `data-invalid` 属性。
### Array Validation
Use Zod's `array` method to validate array fields.
```tsx showLineNumbers title="form.tsx"
const formSchema = z.object({
emails: z
.array(
z.object({
address: z.string().email("Enter a valid email address."),
})
)
.min(1, "Add at least one email address.")
.max(5, "You can add up to 5 email addresses."),
})