- Accordion
- Alert Dialog
- Alert
- Aspect Ratio
- Avatar
- Badge
- Breadcrumb
- Button Group
- Button
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Form
- Hover Card
- Input Group
- Input OTP
- Input
- 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 Group
- Toggle
- Tooltip
- Typography
在本指南中,我们将了解如何在 Next.js 中使用 useActionState 与 Server Actions 构建表单。内容涵盖表单搭建、校验、等待态、可访问性等多个方面。
演示
我们将实现一个包含单行输入与多行文本区域的表单。提交后会通过 Server Action 校验表单数据并更新表单状态。
Component form-next-demo not found in registry.
注意: 为了演示如何在 Server Action 中执行模式校验并展示错误,本页示例特意关闭了浏览器级别的验证。实际生产环境可视情况开启基础校验。
实现思路
该表单依托 Next.js 与 React 的原生表单能力。我们会围绕 <Field /> 组件搭建表单,以获得对标记与样式的完全掌控。
- 使用 Next.js 的
<Form />组件以支持导航与渐进增强。 - 借助
<Field />组件构建可访问的表单。 - 使用
useActionState管理表单状态与错误。 - 通过
pending属性处理加载状态。 - 使用 Server Actions 处理表单提交。
- 在服务端利用 Zod 完成校验。
结构
下面是一个使用 <Field /> 组件的基础表单示例。
<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>使用方式
创建表单模式
首先在 schema.ts 文件中使用 Zod 定义表单的数据结构。
注意: 示例使用 zod v3 执行模式校验,你也可以替换成其他符合 Standard Schema 规范的校验库。
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."),
})定义表单状态类型
接下来定义一个包含表单值、错误与成功状态的类型,便于在客户端和服务端共享。
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 类型放在独立文件中,便于在客户端组件与服务端逻辑之间复用。
创建 Server Action
Server Action 是运行在服务端并能从客户端调用的函数。我们通过它来校验表单数据并更新表单状态。
"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 管理表单状态、Server Action 以及等待态。
"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。该 Server Action 会验证表单数据并更新表单状态。
若数据无效,Server Action 会把错误返回给客户端;若数据有效,则返回成功状态并更新表单。
等待状态
利用 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">Name</FieldLabel>
<Input id="name" name="name" disabled={pending} />
</Field>
<Field>
<Button type="submit" disabled={pending}>
{pending && <Spinner />} Submit
</Button>
</Field>
</FieldGroup>
</Form>
)
}禁用状态
提交按钮
通过 pending 状态控制按钮的 disabled 属性,可以在加载时禁用提交按钮。
<Button type="submit" disabled={pending}>
{pending && <Spinner />} Submit
</Button>表单字段
若要为 <Field /> 组件设置禁用态与样式,可在组件上添加 data-disabled 属性。
<Field data-disabled={pending}>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" disabled={pending} />
</Field>校验
服务端校验
在 Server Action 中使用模式的 safeParse() 方法验证表单数据。
"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,
}
}业务逻辑校验
可以在 Server Action 中加入额外的业务校验逻辑。
当校验失败时务必返回用户输入的值,以便保持表单状态。
"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,
}
}展示错误
使用 <FieldError /> 在字段旁展示错误信息。别忘了在 <Field /> 上添加 data-invalid 属性,并在输入控件上设置 aria-invalid。
<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>重置表单
当通过 Server Action 提交表单时,React 会自动将表单状态重置为初始值。
成功后重置
若希望成功提交后重置表单,只需在 Server Action 的返回值中省略 values,React 会自动恢复初始状态,这是 React 的默认行为。
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,
}
}校验失败时保留输入
如果想在校验失败时保留用户输入,可以在 Server Action 中返回 values,以确保表单状态不会被重置。
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,
}
}
}复杂表单
下面提供一个包含多字段与复杂校验的示例。
Component form-next-complex not found in registry.
模式
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
表单
"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'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
"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,
}
}