- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Avatar
- Badge
- Breadcrumb
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input
- Input Group
- Input OTP
- 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
- Toggle Group
- Tooltip
- Typography
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.
演示
我们将使用一个简单的文本输入框和一个文本域来构建下面这个表单。提交时,我们会使用 server action 验证表单数据并更新表单状态。
"use client"
import * as React from "react"注意: 本页示例故意禁用了浏览器校验,以展示 schema 校验和表单错误在 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">错误标题</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>用法
创建表单 schema
首先,我们在 schema.ts 文件中使用 Zod schema 定义表单结构。
Note: This example uses zod v3 for schema validation, but you can
replace it with any other schema validation library. Make sure your schema
library conforms to the Standard Schema specification.
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."),
})定义表单状态类型
接着,我们创建一个包含 values、errors 和 success 状态的表单状态类型。它会用于给客户端和服务端的表单状态加类型。
import { z } from "zod"
export type FormState = {
values?: z.infer<typeof formSchema>
errors: null | Partial<Record<keyof z.infer<typeof formSchema>, string[]>>
success: boolean
}重要: 我们把 schema 和 FormState 类型放在单独的文件中,这样就可以同时在客户端和服务端组件中导入它们。
创建 Server Action
A 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 hook 来管理表单状态、server action 和 pending 状态。
"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 会验证表单数据并更新表单状态。
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 状态
使用 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
要为 <Field /> 组件应用禁用状态和样式,请使用 <Field /> 组件上的 data-disabled 属性。
<Field data-disabled={pending}>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" disabled={pending} />
</Field>校验
服务端校验
在 server action 中对 schema 使用 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 中加入额外的自定义校验逻辑。
在校验失败时务必返回 values,这样才能确保表单状态保留用户输入。
"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,
}
// 校验。
if (!result.success) {
return {
values,
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// 业务逻辑。
callYourDatabaseOrAPI(values)
// 成功时省略 values,以重置表单状态。
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 {
// 校验失败时返回 values。
values,
success: false,
errors: result.error.flatten().fieldErrors,
}
}
}复杂表单
下面是一个包含多个字段和校验的更复杂表单示例。
Schema
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,
}
}