🌐 Nodejs.cn

React Hook 表单

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

在本指南中,我们将研究如何使用 React Hook Form 构建表单。我们将介绍如何使用 <Field /> 组件构建表单、使用 Zod 添加模式验证、错误处理、可访问性等内容。

🌐 In this guide, we will take a look at building forms with React Hook Form. We'll cover building forms with the <Field /> component, adding schema validation using Zod, error handling, accessibility, and more.

演示

🌐 Demo

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

🌐 We are going to build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.

Bug Report
Help us improve by reporting bugs you encounter.
0/100 characters

Include steps to reproduce, expected behavior, and what actually happened.

"use client"

import * as React from "react"

方法

🌐 Approach

这个表单利用 React Hook Form 来实现高性能、灵活的表单处理。我们将使用 <Field /> 组件来构建表单,这让你可以完全自由地控制标记和样式

🌐 This form leverages React Hook Form for performant, flexible form handling. We'll build our form using the <Field /> component, which gives you complete flexibility over the markup and styling.

  • 使用 React Hook Form 的 useForm 钩子进行表单状态管理。
  • <Controller /> 组件用于受控输入。
  • <Field /> 组件用于构建可访问的表单。
  • 使用 Zod 和 zodResolver 的客户端验证。

剖析

🌐 Anatomy

这是一个使用 React Hook Form 的 <Controller /> 组件和 <Field /> 组件的表单的基本示例。

🌐 Here's a basic example of a form using the <Controller /> component from React Hook Form and the <Field /> component.

<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>
  )}
/>

表单

🌐 Form

创建表单模式

🌐 Create a form schema

我们将首先使用 Zod 模式来定义我们表单的形状

🌐 We'll start by defining the shape of our form using a Zod schema

form.tsx
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."),
})

设置表单

🌐 Setup the form

接下来,我们将使用 React Hook Form 的 useForm 钩子来创建我们的表单实例。我们还将添加 Zod 解析器来验证表单数据。

🌐 Next, we'll use the useForm hook from React Hook Form to create our form instance. We'll also add the Zod resolver to validate the form data.

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, "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>) {
    // Do something with the form values.
    console.log(data)
  }
 
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      
      
      
    </form>
  )
}

建立表单

🌐 Build the form

我们现在可以使用 React Hook Form 的 <Controller /> 组件和 <Field /> 组件来构建表单。

🌐 We can now build the form using the <Controller /> component from React Hook Form and the <Field /> component.

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="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>
  )
}

完成

🌐 Done

就是这样。你现在有一个完全可访问的表格,并带有客户端验证。

🌐 That's it. You now have a fully accessible form with client-side validation.

当你提交表单时,onSubmit 函数将使用验证过的表单数据被调用。如果表单数据无效,React Hook Form 会在每个字段旁显示错误信息。

🌐 When you submit the form, the onSubmit function will be called with the validated form data. If the form data is invalid, React Hook Form will display the errors next to each field.

验证

🌐 Validation

客户端验证

🌐 Client-side Validation

React Hook Form 使用 Zod 模式验证你的表单数据。定义一个模式并将其传递给 useForm 钩子的 resolver 选项。

🌐 React Hook Form validates your form data using the Zod schema. Define a schema and pass it to the resolver option of the useForm hook.

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: "",
    },
  })
}

验证模式

🌐 Validation Modes

React Hook Form 支持不同的验证模式。

🌐 React Hook Form supports different validation modes.

form.tsx
const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  mode: "onChange",
})
模式描述
"onChange"每次更改时触发验证。
"onBlur"失去焦点时触发验证。
"onSubmit"提交时触发验证(默认)。
"onTouched"首次失去焦点后触发验证,然后每次更改时触发验证。
"all"失去焦点和更改时触发验证。

显示错误

🌐 Displaying Errors

使用 <FieldError /> 在字段旁显示错误。用于样式和可访问性:

🌐 Display errors next to the field using <FieldError />. For styling and accessibility:

  • data-invalid 属性添加到 <Field /> 组件中。
  • aria-invalid 属性添加到表单控件中,例如 <Input /><SelectTrigger /><Checkbox /> 等。
form.tsx
<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

  • 对于输入字段,将 field 对象展开到 <Input /> 组件上。
  • 要显示错误,请在 <Input /> 组件中添加 aria-invalid 属性,在 <Field /> 组件中添加 data-invalid 属性。
Profile Settings
Update your profile information below.

This is your public display name. Must be between 3 and 10 characters. Must only contain letters, numbers, and underscores.

"use client"

import { zodResolver } from "@hookform/resolvers/zod"

对于简单的文本输入,将 field 对象扩展到输入中。

🌐 For simple text inputs, spread the field object onto the input.

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

  • 对于文本区域字段,将 field 对象展开到 <Textarea /> 组件上。
  • 要显示错误,请在 <Textarea /> 组件中添加 aria-invalid 属性,在 <Field /> 组件中添加 data-invalid 属性。
Personalization
Customize your experience by telling us more about yourself.

Tell us more about yourself. This will be used to help us personalize your experience.

"use client"

import * as React from "react"

对于文本区域字段,将 field 对象展开到文本区域上。

🌐 For textarea fields, spread the field object onto the textarea.

form.tsx
<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-[120px]"
      />
      <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

  • 对于某些组件,在 <Select /> 组件上使用 field.valuefield.onChange
  • 要显示错误,请在 <SelectTrigger /> 组件中添加 aria-invalid 属性,在 <Field /> 组件中添加 data-invalid 属性。
Language Preferences
Select your preferred spoken language.

For best results, select the language you speak.

"use client"

import * as React from "react"
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">
          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-[120px]"
        >
          <SelectValue placeholder="Select" />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          <SelectItem value="auto">Auto</SelectItem>
          <SelectItem value="en">English</SelectItem>
        </SelectContent>
      </Select>
    </Field>
  )}
/>

复选框

🌐 Checkbox

  • 对于复选框数组,使用 field.valuefield.onChange 进行数组操作。
  • 要显示错误,请在 <Checkbox /> 组件中添加 aria-invalid 属性,在 <Field /> 组件中添加 data-invalid 属性。
  • 记得将 data-slot="checkbox-group" 添加到 <FieldGroup /> 组件中,以获得正确的样式和间距。
Notifications
Manage your notification preferences.
Responses

Get notified for requests that take time, like research or image generation.

Tasks

Get notified when tasks you've created have updates.

"use client"

import * as React from "react"
form.tsx
<Controller
  name="tasks"
  control={form.control}
  render={({ field, fieldState }) => (
    <FieldSet>
      <FieldLegend variant="label">Tasks</FieldLegend>
      <FieldDescription>
        Get notified when tasks you&apos;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

  • 对于单选按钮组,在 <RadioGroup /> 组件上使用 field.valuefield.onChange
  • 要显示错误,请在 <RadioGroupItem /> 组件中添加 aria-invalid 属性,在 <Field /> 组件中添加 data-invalid 属性。
Subscription Plan
See pricing and features for each plan.
Plan

You can upgrade or downgrade your plan at any time.

"use client"

import * as React from "react"
form.tsx
<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

  • 对于开关,使用 <Switch /> 组件上的 field.valuefield.onChange
  • 要显示错误,请在 <Switch /> 组件中添加 aria-invalid 属性,在 <Field /> 组件中添加 data-invalid 属性。
Security Settings
Manage your account security preferences.

Enable multi-factor authentication to secure your account.

"use client"

import * as React from "react"
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">
          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.

You're almost there!
Choose your subscription plan and billing period.
Subscription Plan

Choose your subscription plan.

Choose how often you want to be billed.

Add-ons

Select additional features you'd like to include.

Advanced analytics and reporting

Automated daily backups

24/7 premium customer support

Receive email updates about your subscription

"use client"

import * as React from "react"

重置表单

🌐 Resetting the Form

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

🌐 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 提供了一个 useFieldArray 钩子用于管理动态数组字段。当你需要动态添加或移除字段时,这非常有用。

🌐 React Hook Form provides a useFieldArray hook for managing dynamic array fields. This is useful when you need to add or remove fields dynamically.

Contact Emails
Manage your contact email addresses.
Email Addresses

Add up to 5 email addresses where we can contact you.

"use client"

import * as React from "react"

使用 useFieldArray

🌐 Using useFieldArray

使用 useFieldArray 钩子来管理数组字段。它提供了 fieldsappendremove 方法。

🌐 Use the useFieldArray hook to manage array fields. It provides fields, append, and remove methods.

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

将你的数组字段用一个 <FieldSet /> 封装,并搭配一个 <FieldLegend /><FieldDescription />

🌐 Wrap your array fields in a <FieldSet /> with a <FieldLegend /> and <FieldDescription />.

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

数组项的控制器模式

🌐 Controller Pattern for Array Items

fields 数组进行映射,并对每个项目使用 <Controller />确保使用 field.id 作为 key

🌐 Map over the fields array and use <Controller /> for each item. Make sure to use field.id as the 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>
      )}
    />
  ))
}

添加项目

🌐 Adding Items

使用 append 方法向数组中添加新项。

🌐 Use the append method to add new items to the array.

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => append({ address: "" })}
  disabled={fields.length >= 5}
>
  Add Email Address
</Button>

移除条目

🌐 Removing Items

使用 remove 方法从数组中移除项。有条件地添加删除按钮。

🌐 Use the remove method to remove items from the array. Add the remove button conditionally.

form.tsx
{
  fields.length > 1 && (
    <InputGroupAddon align="inline-end">
      <InputGroupButton
        type="button"
        variant="ghost"
        size="icon-xs"
        onClick={() => remove(index)}
        aria-label={`Remove email ${index + 1}`}
      >
        <XIcon />
      </InputGroupButton>
    </InputGroupAddon>
  )
}

数组验证

🌐 Array Validation

使用 Zod 的 array 方法来验证数组字段。

🌐 Use Zod's array method to validate array fields.

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."),
})