98.0k

React Hook Form

PreviousNext

使用 React Hook Form 和 Zod 构建 React 表单。

在本指南中,我们将了解如何使用 React Hook Form 构建表单。内容包括使用 <Field /> 组件构建表单、使用 Zod 添加模式验证、错误处理、无障碍支持等。

演示

我们将构建如下表单。它包含一个简单的文本输入框和一个多行文本框。提交时,我们将验证表单数据并显示任何错误。

方法

该表单利用 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 模式定义表单结构。

form.tsx
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 解析器用于数据验证。

form.tsx
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 /> 组件构建表单。

form.tsx
"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 选项。

example-form.tsx
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 支持多种验证模式。

form.tsx
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 属性。
form.tsx
<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 属性。

对于简单文本输入,将 field 对象展开应用于输入框。

form.tsx
<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 属性。

对于多行文本,将 field 对象展开应用到多行文本框。

form.tsx
<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.valuefield.onChange
  • 显示错误时,在 <SelectTrigger /> 组件上添加 aria-invalid,在 <Field /> 组件上添加 data-invalid
form.tsx
<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.valuefield.onChange,并操作数组。
  • 显示错误时,向 <Checkbox /> 组件添加 aria-invalid,向 <Field /> 组件添加 data-invalid
  • 记得在 <FieldGroup /> 组件上添加 data-slot="checkbox-group" 以获得正确的样式和间距。
form.tsx
<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.valuefield.onChange
  • 显示错误时,向 <RadioGroupItem /> 组件添加 aria-invalid,向 <Field /> 组件添加 data-invalid
form.tsx
<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.valuefield.onChange
  • 显示错误时,向 <Switch /> 组件添加 aria-invalid,向 <Field /> 组件添加 data-invalid
form.tsx
<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>
  )}
/>

复杂表单

这是一个包含多个字段和验证的复杂表单示例。

重置表单

使用 form.reset() 重置表单到默认值。

<Button type="button" variant="outline" onClick={() => form.reset()}>
  重置
</Button>

数组字段

React Hook Form 提供了 useFieldArray 钩子来管理动态数组字段,适用于需要动态添加或删除字段的场景。

使用 useFieldArray

使用 useFieldArray 来管理数组字段,它会返回 fieldsappendremove 方法。

form.tsx
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 />

form.tsx
<FieldSet className="gap-4">
  <FieldLegend variant="label">邮箱地址</FieldLegend>
  <FieldDescription>
    添加最多5个我们可以联系您的邮箱地址。
  </FieldDescription>
  <FieldGroup className="gap-4">{/* 数组项放置处 */}</FieldGroup>
</FieldSet>

数组项的 Controller 方式

遍历 fields 数组并对每项使用 <Controller />务必使用 field.id 作为 key。

form.tsx
{
  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 方法向数组添加新项。

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => append({ address: "" })}
  disabled={fields.length >= 5}
>
  添加邮箱地址
</Button>

移除项

使用 remove 方法删除数组项,条件渲染删除按钮。

form.tsx
{
  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 方法来验证数组字段。

form.tsx
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email("请输入有效的邮箱地址。"),
      })
    )
    .min(1, "请至少添加一个邮箱地址。")
    .max(5, "最多可添加5个邮箱地址。"),
})