🌐 Nodejs.cn

使用 useActionState 和服务器操作在 React 中构建表单。

在本指南中,我们将着眼于使用 useActionState 和服务器操作在 Next.js 中构建表单。我们将涵盖构建表单、验证、等待状态、可访问性等内容。

🌐 In this guide, we will take a look at building forms with Next.js using useActionState and Server Actions. We'll cover building forms, validation, pending states, accessibility, and more.

演示

🌐 Demo

我们将构建以下表单,包含一个简单的文本输入框和一个文本区域。在提交时,我们将使用服务器操作来验证表单数据并更新表单状态。

🌐 We are going to build the following form with a simple text input and a textarea. On submit, we'll use a server action to validate the form data and update the form state.

Component form-next-demo not found in registry.

方法

🌐 Approach

此表单利用了 Next.js 和 React 内置的表单处理功能。我们将使用 <Field /> 组件来构建表单,该组件为你提供 对标记和样式的完全灵活性

🌐 This form leverages Next.js and React's built-in capabilities for form handling. We'll build our form using the <Field /> component, which gives you complete flexibility over the markup and styling.

  • 使用 Next.js <Form /> 组件进行导航和渐进增强。
  • <Field /> 组件用于构建可访问的表单。
  • useActionState用于管理表单状态和错误。
  • 使用 pending 属性处理加载状态。
  • 用于处理表单提交的服务器操作。
  • 使用 Zod 进行服务器端验证。

剖析

🌐 Anatomy

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

🌐 Here's a basic example of a form using the <Field /> component.

<Form action={formAction}>
  <FieldGroup>
    <Field data-invalid={!!formState.errors?.title?.length}>
      <FieldLabel htmlFor="title">Bug Title</FieldLabel>
      <Input
        id="title"
        name="title"
        defaultValue={formState.values.title}
        disabled={pending}
        aria-invalid={!!formState.errors?.title?.length}
        placeholder="Login button not working on mobile"
        autoComplete="off"
      />
      <FieldDescription>
        Provide a concise title for your bug report.
      </FieldDescription>
      {formState.errors?.title && (
        <FieldError>{formState.errors.title[0]}</FieldError>
      )}
    </Field>
  </FieldGroup>
  <Button type="submit">Submit</Button>
</Form>

用法

🌐 Usage

创建表单模式

🌐 Create a form schema

我们将首先使用 Zod 架构在 schema.ts 文件中定义我们表单的形状。

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

schema.ts
import { z } from "zod"
 
export 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."),
})

定义表单状态类型

🌐 Define the form state type

接下来,我们将为表单状态创建一个类型,其中包括值、错误和成功状态。这将用于在客户端和服务器上为表单状态指定类型。

🌐 Next, we'll create a type for our form state that includes values, errors, and success status. This will be used to type the form state on the client and server.

schema.ts
import { z } from "zod"
 
export type FormState = {
  values?: z.infer<typeof formSchema>
  errors: null | Partial<Record<keyof z.infer<typeof formSchema>, string[]>>
  success: boolean
}

重要提示: 我们在一个单独的文件中定义模式和 FormState 类型,这样我们就可以将它们导入到客户端和服务器组件中。

创建服务器操作

🌐 Create the Server Action

服务器操作是一个在服务器上运行的函数,可以从客户端调用。我们将用它来验证表单数据并更新表单状态。

🌐 A server action is a function that runs on the server and can be called from the client. We'll use it to validate the form data and update the form state.

actions.ts
"use server"

import { formSchema, type FormState } from "./form-next-demo-schema"

export async function demoFormAction(
  _prevState: FormState,
  formData: FormData
) {
  const values = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
  }

  const result = formSchema.safeParse(values)

  if (!result.success) {
    return {
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }

  // Do something with the values.
  // Call your database or API here.

  return {
    values: {
      title: "",
      description: "",
    },
    errors: null,
    success: true,
  }
}

注意: 我们在错误情况下返回 values。这是因为我们希望在表单状态中保留用户提交的值。对于成功的情况,我们返回空值以重置表单。

建立表单

🌐 Build the form

我们现在可以使用 <Field /> 组件来构建表单。我们将使用 useActionState 钩子来管理表单状态、服务器操作以及挂起状态。

🌐 We can now build the form using the <Field /> component. We'll use the useActionState hook to manage the form state, server action, and pending state.

form.tsx
"use client"

import * as React from "react"
import Form from "next/form"
import { toast } from "sonner"

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"
import { Spinner } from "@/components/ui/spinner"

import { demoFormAction } from "./form-next-demo-action"
import { type FormState } from "./form-next-demo-schema"

export function FormNextDemo() {
  const [formState, formAction, pending] = React.useActionState<
    FormState,
    FormData
  >(demoFormAction, {
    values: {
      title: "",
      description: "",
    },
    errors: null,
    success: false,
  })
  const [descriptionLength, setDescriptionLength] = React.useState(0)

  React.useEffect(() => {
    if (formState.success) {
      toast("Thank you for your feedback", {
        description: "We'll review your report and get back to you soon.",
      })
    }
  }, [formState.success])

  React.useEffect(() => {
    setDescriptionLength(formState.values.description.length)
  }, [formState.values.description])

  return (
    <Card className="w-full max-w-md">
      <CardHeader>
        <CardTitle>Bug Report</CardTitle>
        <CardDescription>
          Help us improve by reporting bugs you encounter.
        </CardDescription>
      </CardHeader>
      <CardContent>
        <Form action={formAction} id="bug-report-form">
          <FieldGroup>
            <Field data-invalid={!!formState.errors?.title?.length}>
              <FieldLabel htmlFor="title">Bug Title</FieldLabel>
              <Input
                id="title"
                name="title"
                defaultValue={formState.values.title}
                disabled={pending}
                aria-invalid={!!formState.errors?.title?.length}
                placeholder="Login button not working on mobile"
                autoComplete="off"
              />
              {formState.errors?.title && (
                <FieldError>{formState.errors.title[0]}</FieldError>
              )}
            </Field>
            <Field data-invalid={!!formState.errors?.description?.length}>
              <FieldLabel htmlFor="description">Description</FieldLabel>
              <InputGroup>
                <InputGroupTextarea
                  id="description"
                  name="description"
                  defaultValue={formState.values.description}
                  placeholder="I'm having an issue with the login button on mobile."
                  rows={6}
                  className="min-h-24 resize-none"
                  disabled={pending}
                  aria-invalid={!!formState.errors?.description?.length}
                  onChange={(e) => setDescriptionLength(e.target.value.length)}
                />
                <InputGroupAddon align="block-end">
                  <InputGroupText className="tabular-nums">
                    {descriptionLength}/100 characters
                  </InputGroupText>
                </InputGroupAddon>
              </InputGroup>
              <FieldDescription>
                Include steps to reproduce, expected behavior, and what actually
                happened.
              </FieldDescription>
              {formState.errors?.description && (
                <FieldError>{formState.errors.description[0]}</FieldError>
              )}
            </Field>
          </FieldGroup>
        </Form>
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button type="submit" disabled={pending} form="bug-report-form">
            {pending && <Spinner />}
            Submit
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}

完成

🌐 Done

就是这样。你现在拥有一个具有客户端和服务器端验证的完全可访问表单。

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

当你提交表单时,服务器将调用 formAction 函数。服务器操作将验证表单数据并更新表单状态。

🌐 When you submit the form, the formAction function will be called on the server. The server action will validate the form data and update the form state.

如果表单数据无效,服务器操作将返回错误给客户端。如果表单数据有效,服务器操作将返回成功状态并更新表单状态。

🌐 If the form data is invalid, the server action will return the errors to the client. If the form data is valid, the server action will return the success status and update the form state.

待处理状态

🌐 Pending States

使用 useActionStatepending 属性来显示加载指示器并禁用表单输入。

🌐 Use the pending prop from useActionState to show loading indicators and disable form inputs.

"use client"
 
import * as React from "react"
import Form from "next/form"
 
import { Spinner } from "@/components/ui/spinner"
 
import { bugReportFormAction } from "./actions"
 
export function BugReportForm() {
  const [formState, formAction, pending] = React.useActionState(
    bugReportFormAction,
    {
      errors: null,
      success: false,
    }
  )
 
  return (
    <Form action={formAction}>
      <FieldGroup>
        <Field data-disabled={pending}>
          <FieldLabel htmlFor="name">Name</FieldLabel>
          <Input id="name" name="name" disabled={pending} />
        </Field>
        <Field>
          <Button type="submit" disabled={pending}>
            {pending && <Spinner />} Submit
          </Button>
        </Field>
      </FieldGroup>
    </Form>
  )
}

禁用状态

🌐 Disabled States

提交按钮

🌐 Submit Button

要禁用提交按钮,请在按钮的 disabled 属性上使用 pending 属性。

🌐 To disable the submit button, use the pending prop on the button's disabled prop.

<Button type="submit" disabled={pending}>
  {pending && <Spinner />} Submit
</Button>

字段

🌐 Field

要对 <Field /> 组件应用禁用状态和样式,请在 <Field /> 组件上使用 data-disabled 属性。

🌐 To apply a disabled state and styling to a <Field /> component, use the data-disabled prop on the <Field /> component.

<Field data-disabled={pending}>
  <FieldLabel htmlFor="name">Name</FieldLabel>
  <Input id="name" name="name" disabled={pending} />
</Field>

验证

🌐 Validation

服务器端验证

🌐 Server-side Validation

在你的服务器操作中对你的模式使用 safeParse() 来验证表单数据。

🌐 Use safeParse() on your schema in your server action to validate the form data.

actions.ts
"use server"
 
export async function bugReportFormAction(
  _prevState: FormState,
  formData: FormData
) {
  const values = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
  }
 
  const result = formSchema.safeParse(values)
 
  if (!result.success) {
    return {
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }
 
  return {
    errors: null,
    success: true,
  }
}

业务逻辑验证

🌐 Business Logic Validation

你可以在服务器操作中添加额外的自定义验证逻辑。

🌐 You can add additional custom validation logic in your server action.

请确保在验证错误时返回值。这是为了确保表单状态保持用户的输入。

🌐 Make sure to return the values on validation errors. This is to ensure that the form state maintains the user's input.

actions.ts
"use server"
 
export async function bugReportFormAction(
  _prevState: FormState,
  formData: FormData
) {
  const values = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
  }
 
  const result = formSchema.safeParse(values)
 
  if (!result.success) {
    return {
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }
 
  // Check if email already exists in database.
  const existingUser = await db.user.findUnique({
    where: { email: result.data.email },
  })
 
  if (existingUser) {
    return {
      values,
      success: false,
      errors: {
        email: ["This email is already registered"],
      },
    }
  }
 
  return {
    errors: null,
    success: true,
  }
}

显示错误

🌐 Displaying Errors

使用 <FieldError /> 在字段旁显示错误。确保向 <Field /> 组件添加 data-invalid 属性,并向输入框添加 aria-invalid 属性。

🌐 Display errors next to the field using <FieldError />. Make sure to add the data-invalid prop to the <Field /> component and aria-invalid prop to the input.

<Field data-invalid={!!formState.errors?.email?.length}>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input
    id="email"
    name="email"
    type="email"
    aria-invalid={!!formState.errors?.email?.length}
  />
  {formState.errors?.email && (
    <FieldError>{formState.errors.email[0]}</FieldError>
  )}
</Field>

重置表单

🌐 Resetting the Form

当你提交带有服务器操作的表单时,React 会自动将表单状态重置为初始值。

🌐 When you submit a form with a server action, React will automatically reset the form state to the initial values.

成功时重置

🌐 Reset on Success

要在成功时重置表单,你可以从服务器操作中省略 values,React 将自动将表单状态重置为初始值。这是标准的 React 行为。

🌐 To reset the form on success, you can omit the values from the server action and React will automatically reset the form state to the initial values. This is standard React behavior.

actions.ts
export async function demoFormAction(
  _prevState: FormState,
  formData: FormData
) {
  const values = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
  }
 
  // Validation.
  if (!result.success) {
    return {
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }
 
  // Business logic.
  callYourDatabaseOrAPI(values)
 
  // Omit the values on success to reset the form state.
  return {
    errors: null,
    success: true,
  }
}

在验证错误时保留

🌐 Preserve on Validation Errors

为了防止表单在失败时被重置,你可以在服务器操作中返回值。这是为了确保表单状态保持用户的输入。

🌐 To prevent the form from being reset on failure, you can return the values in the server action. This is to ensure that the form state maintains the user's input.

actions.ts
export async function demoFormAction(
  _prevState: FormState,
  formData: FormData
) {
  const values = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
  }
 
  // Validation.
  if (!result.success) {
    return {
      // Return the values on validation errors.
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }
}

复杂形式

🌐 Complex Forms

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

🌐 Here is an example of a more complex form with multiple fields and validation.

Component form-next-complex not found in registry.

模式

🌐 Schema

schema.ts
import { z } from "zod"

export const formSchema = z.object({
  plan: z
    .string({
      required_error: "Please select a subscription plan",
    })
    .min(1, "Please select a subscription plan")
    .refine((value) => value === "basic" || value === "pro", {
      message: "Invalid plan selection. Please choose Basic or Pro",
    }),
  billingPeriod: z
    .string({
      required_error: "Please select a billing period",
    })
    .min(1, "Please select a billing period"),
  addons: z
    .array(z.string())
    .min(1, "Please select at least one add-on")
    .max(3, "You can select up to 3 add-ons")
    .refine(
      (value) => value.every((addon) => addons.some((a) => a.id === addon)),
      {
        message: "You selected an invalid add-on",
      }
    ),
  emailNotifications: z.boolean(),
})

export type FormState = {
  values: z.infer<typeof formSchema>
  errors: null | Partial<Record<keyof z.infer<typeof formSchema>, string[]>>
  success: boolean
}

export const addons = [
  {
    id: "analytics",
    title: "Analytics",
    description: "Advanced analytics and reporting",
  },
  {
    id: "backup",
    title: "Backup",
    description: "Automated daily backups",
  },
  {
    id: "support",
    title: "Priority Support",
    description: "24/7 premium customer support",
  },
] as const

表单

🌐 Form

form.tsx
"use client"

import * as React from "react"
import Form from "next/form"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldTitle,
} from "@/components/ui/field"
import {
  RadioGroup,
  RadioGroupItem,
} from "@/components/ui/radio-group"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"

import { complexFormAction } from "./form-next-complex-action"
import { addons, type FormState } from "./form-next-complex-schema"

export function FormNextComplex() {
  const [formState, formAction, pending] = React.useActionState<
    FormState,
    FormData
  >(complexFormAction, {
    values: {
      plan: "basic",
      billingPeriod: "monthly",
      addons: [],
      emailNotifications: false,
    },
    errors: null,
    success: false,
  })

  React.useEffect(() => {
    if (formState.success) {
      toast.success("Preferences saved", {
        description: "Your subscription plan has been updated.",
      })
    }
  }, [formState.success])

  return (
    <Card className="w-full max-w-sm">
      <CardContent>
        <Form action={formAction} id="subscription-form">
          <FieldGroup>
            <FieldSet data-invalid={!!formState.errors?.plan?.length}>
              <FieldLegend>Subscription Plan</FieldLegend>
              <FieldDescription>
                Choose your subscription plan.
              </FieldDescription>
              <RadioGroup
                name="plan"
                defaultValue={formState.values.plan}
                disabled={pending}
                aria-invalid={!!formState.errors?.plan?.length}
              >
                <FieldLabel htmlFor="basic">
                  <Field orientation="horizontal">
                    <FieldContent>
                      <FieldTitle>Basic</FieldTitle>
                      <FieldDescription>
                        For individuals and small teams
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem value="basic" id="basic" />
                  </Field>
                </FieldLabel>
                <FieldLabel htmlFor="pro">
                  <Field orientation="horizontal">
                    <FieldContent>
                      <FieldTitle>Pro</FieldTitle>
                      <FieldDescription>
                        For businesses with higher demands
                      </FieldDescription>
                    </FieldContent>
                    <RadioGroupItem value="pro" id="pro" />
                  </Field>
                </FieldLabel>
              </RadioGroup>
              {formState.errors?.plan && (
                <FieldError>{formState.errors.plan[0]}</FieldError>
              )}
            </FieldSet>
            <FieldSeparator />
            <Field data-invalid={!!formState.errors?.billingPeriod?.length}>
              <FieldLabel htmlFor="billingPeriod">Billing Period</FieldLabel>
              <Select
                name="billingPeriod"
                defaultValue={formState.values.billingPeriod}
                disabled={pending}
                aria-invalid={!!formState.errors?.billingPeriod?.length}
              >
                <SelectTrigger id="billingPeriod">
                  <SelectValue placeholder="Select" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="monthly">Monthly</SelectItem>
                  <SelectItem value="yearly">Yearly</SelectItem>
                </SelectContent>
              </Select>
              <FieldDescription>
                Choose how often you want to be billed.
              </FieldDescription>
              {formState.errors?.billingPeriod && (
                <FieldError>{formState.errors.billingPeriod[0]}</FieldError>
              )}
            </Field>
            <FieldSeparator />
            <FieldSet>
              <FieldLegend>Add-ons</FieldLegend>
              <FieldDescription>
                Select additional features you&apos;d like to include.
              </FieldDescription>
              <FieldGroup data-slot="checkbox-group">
                {addons.map((addon) => (
                  <Field
                    key={addon.id}
                    orientation="horizontal"
                    data-invalid={!!formState.errors?.addons?.length}
                  >
                    <Checkbox
                      id={addon.id}
                      name="addons"
                      value={addon.id}
                      defaultChecked={formState.values.addons.includes(
                        addon.id
                      )}
                      disabled={pending}
                      aria-invalid={!!formState.errors?.addons?.length}
                    />
                    <FieldContent>
                      <FieldLabel htmlFor={addon.id}>{addon.title}</FieldLabel>
                      <FieldDescription>{addon.description}</FieldDescription>
                    </FieldContent>
                  </Field>
                ))}
              </FieldGroup>
              {formState.errors?.addons && (
                <FieldError>{formState.errors.addons[0]}</FieldError>
              )}
            </FieldSet>
            <FieldSeparator />
            <Field orientation="horizontal">
              <FieldContent>
                <FieldLabel htmlFor="emailNotifications">
                  Email Notifications
                </FieldLabel>
                <FieldDescription>
                  Receive email updates about your subscription
                </FieldDescription>
              </FieldContent>
              <Switch
                id="emailNotifications"
                name="emailNotifications"
                defaultChecked={formState.values.emailNotifications}
                disabled={pending}
                aria-invalid={!!formState.errors?.emailNotifications?.length}
              />
            </Field>
          </FieldGroup>
        </Form>
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal" className="justify-end">
          <Button type="submit" disabled={pending} form="subscription-form">
            {pending && <Spinner />}
            Save Preferences
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}

服务器操作

🌐 Server Action

actions.ts
"use server"

import { formSchema, type FormState } from "./form-next-complex-schema"

export async function complexFormAction(
  _prevState: FormState,
  formData: FormData
) {
  // Sleep for 1 second
  await new Promise((resolve) => setTimeout(resolve, 1000))

  const values = {
    plan: formData.get("plan") as FormState["values"]["plan"],
    billingPeriod: formData.get("billingPeriod") as string,
    addons: formData.getAll("addons") as string[],
    emailNotifications: formData.get("emailNotifications") === "on",
  }

  const result = formSchema.safeParse(values)

  if (!result.success) {
    return {
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }

  // Do something with the values.
  // Call your database or API here.

  return {
    values,
    errors: null,
    success: true,
  }
}