109k

Next.js

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

在本指南中,我们将介绍如何使用 Next.js 中的 useActionState 和服务器操作来构建表单。内容涵盖表单构建、验证、挂起状态、无障碍访问等。

演示

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

Component form-next-demo not found in registry.

方法

该表单利用了 Next.js 和 React 内置的表单处理能力。我们将使用 <Field /> 组件构建表单,它为你提供了完全的标记和样式自由

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

结构

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

<Form action={formAction}>
  <FieldGroup>
    <Field data-invalid={!!formState.errors?.title?.length}>
      <FieldLabel htmlFor="title">错误标题</FieldLabel>
      <Input
        id="title"
        name="title"
        defaultValue={formState.values.title}
        disabled={pending}
        aria-invalid={!!formState.errors?.title?.length}
        placeholder="登录按钮在手机上无法使用"
        autoComplete="off"
      />
      <FieldDescription>
        请简洁描述你的错误报告标题。
      </FieldDescription>
      {formState.errors?.title && (
        <FieldError>{formState.errors.title[0]}</FieldError>
      )}
    </Field>
  </FieldGroup>
  <Button type="submit">提交</Button>
</Form>

用法

创建表单架构

首先我们使用 Zod 在 schema.ts 文件中定义表单的数据结构。

schema.ts
import { z } from "zod"
 
export const formSchema = z.object({
  title: z
    .string()
    .min(5, "错误标题至少需要5个字符。")
    .max(32, "错误标题最多32个字符。"),
  description: z
    .string()
    .min(20, "描述至少需要20个字符。")
    .max(100, "描述最多100个字符。"),
})

定义表单状态类型

接下来,我们定义一个表单状态类型,包含值、错误和成功状态。这将在客户端和服务器端用于表单状态的类型定义。

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 类型定义在单独文件,方便在客户端和服务器组件中导入使用。

创建服务器操作

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

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,目的是保持用户提交的值在表单状态中。对于成功情况则返回空的值以重置表单。

构建表单

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

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

完成

就是这样。你现在拥有了一个全无障碍且具备客户端和服务器端验证的完整表单。

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

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

挂起状态

使用 useActionState 返回的 pending 属性来显示加载指示器并禁用表单输入。

"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">姓名</FieldLabel>
          <Input id="name" name="name" disabled={pending} />
        </Field>
        <Field>
          <Button type="submit" disabled={pending}>
            {pending && <Spinner />} 提交
          </Button>
        </Field>
      </FieldGroup>
    </Form>
  )
}

禁用状态

提交按钮

要禁用提交按钮,请将 pending 传给按钮的 disabled 属性。

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

字段

要为 <Field /> 组件应用禁用状态及样式,请使用 <Field /> 组件的 data-disabled 属性。

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

验证

服务器端验证

在服务器操作中使用架构的 safeParse() 方法验证表单数据。

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,
  }
}

业务逻辑验证

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

在验证出错时,一定要返回 values,以确保表单状态保留用户输入的数据。

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,
    }
  }
 
  // 检查邮箱是否已存在数据库中。
  const existingUser = await db.user.findUnique({
    where: { email: result.data.email },
  })
 
  if (existingUser) {
    return {
      values,
      success: false,
      errors: {
        email: ["该邮箱已被注册"],
      },
    }
  }
 
  return {
    errors: null,
    success: true,
  }
}

错误显示

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

<Field data-invalid={!!formState.errors?.email?.length}>
  <FieldLabel htmlFor="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>

重置表单

使用服务器操作提交表单时,React 会自动重置表单状态为初始值。

成功时重置

成功时可省略服务器操作返回的 values,React 将自动将表单重置为初始状态。这是 React 的标准行为。

actions.ts
export async function demoFormAction(
  _prevState: FormState,
  formData: FormData
) {
  const values = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
  }
 
  // 验证。
  if (!result.success) {
    return {
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }
 
  // 业务逻辑。
  callYourDatabaseOrAPI(values)
 
  // 成功时省略 values, 以重置表单状态。
  return {
    errors: null,
    success: true,
  }
}

验证错误时保留输入

为防止验证失败时重置表单,可以在服务器操作中返回 values,确保保留用户输入。

actions.ts
export async function demoFormAction(
  _prevState: FormState,
  formData: FormData
) {
  const values = {
    title: formData.get("title") as string,
    description: formData.get("description") as string,
  }
 
  // 验证。
  if (!result.success) {
    return {
      // 验证错误时返回 values。
      values,
      success: false,
      errors: result.error.flatten().fieldErrors,
    }
  }
}

复杂表单

以下示例展示了更复杂的包含多个字段和验证的表单。

Component form-next-complex not found in registry.

架构

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

服务器操作

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,
  }
}