- 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
本指南将介绍如何使用 TanStack Form 构建表单。你将学习如何使用 <Field /> 组件创建表单、如何使用 Zod 实现 schema 校验、如何处理错误,以及如何确保可访问性。
演示
我们会从下面这个表单开始。它包含一个简单的文本输入框和一个文本域。提交时,我们会校验表单数据并显示任何错误。
注意: 为了演示效果,我们故意禁用了浏览器校验,以展示 TanStack Form 中的 schema 校验和表单错误是如何工作的。建议你在生产代码中添加基础的浏览器校验。
"use client"
import * as React from "react"思路
这个表单利用 TanStack Form 提供强大且无头的表单处理能力。我们会使用 <Field /> 组件来构建表单,它让你对 标记和样式拥有完全的灵活性。
- 使用 TanStack Form 的
useFormhook 来管理表单状态。 - 使用
form.Field组件和 render prop 模式来处理受控输入。 - 使用
<Field />组件构建可访问的表单。 - 使用 Zod 进行客户端校验。
- 提供实时校验反馈。
结构
下面是一个使用 TanStack Form 和 <Field /> 组件的基础表单示例。
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<FieldGroup>
<form.Field
name="title"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
<FieldDescription>
Provide a concise title for your bug report.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
)
}}
/>
</FieldGroup>
<Button type="submit">Submit</Button>
</form>表单
创建 schema
首先,我们使用 Zod schema 定义表单的结构。
注意: 这个示例使用 zod v3 做 schema 校验。TanStack Form 通过其 validators API 可以无缝集成 Zod 和其他 Standard Schema 校验库。
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."),
})配置表单
使用 TanStack Form 的 useForm hook 创建表单实例,并启用 Zod 校验。
import { useForm } from "@tanstack/react-form"
import { toast } from "sonner"
import * as z from "zod"
const formSchema = z.object({
// ...
})
export function BugReportForm() {
const form = useForm({
defaultValues: {
title: "",
description: "",
},
validators: {
onSubmit: formSchema,
},
onSubmit: async ({ value }) => {
toast.success("表单提交成功")
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
{/* ... */}
</form>
)
}这里我们使用 onSubmit 来校验表单数据。TanStack Form 还支持其他校验模式,你可以在文档中了解更多。
构建表单
现在我们可以使用 TanStack Form 的 form.Field 组件和 <Field /> 组件来构建表单。
"use client"
import * as React from "react"
import { useForm } from "@tanstack/react-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({
defaultValues: {
title: "",
description: "",
},
validators: {
onSubmit: formSchema,
},
onSubmit: async ({ value }) => {
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(value, 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="bug-report-form"
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<FieldGroup>
<form.Field
name="title"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
)
}}
/>
<form.Field
name="description"
children={(field) => {
const isInvalid =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
<InputGroup>
<InputGroupTextarea
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="I'm having an issue with the login button on mobile."
rows={6}
className="min-h-24 resize-none"
aria-invalid={isInvalid}
/>
<InputGroupAddon align="block-end">
<InputGroupText className="tabular-nums">
{field.state.value.length}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>
Include steps to reproduce, expected behavior, and what
actually happened.
</FieldDescription>
{isInvalid && (
<FieldError errors={field.state.meta.errors} />
)}
</Field>
)
}}
/>
</FieldGroup>
</form>
</CardContent>
<CardFooter>
<Field orientation="horizontal">
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit" form="bug-report-form">
Submit
</Button>
</Field>
</CardFooter>
</Card>
)
}
完成
就这样。现在你已经有了一个带客户端校验、完全可访问的表单。
提交表单时,onSubmit 函数会接收到已校验的表单数据。如果表单数据无效,TanStack Form 会在每个字段旁边显示错误。
校验
客户端校验
TanStack Form 会使用 Zod schema 校验你的表单数据。校验会在用户输入时实时发生。
import { useForm } from "@tanstack/react-form"
const formSchema = z.object({
// ...
})
export function BugReportForm() {
const form = useForm({
defaultValues: {
title: "",
description: "",
},
validators: {
onSubmit: formSchema,
},
onSubmit: async ({ value }) => {
console.log(value)
},
})
return <form onSubmit={/* ... */}>{/* ... */}</form>
}校验模式
TanStack Form 通过 validators 选项支持不同的校验策略:
| 模式 | 描述 |
|---|---|
"onChange" | 每次变化时触发校验。 |
"onBlur" | 失焦时触发校验。 |
"onSubmit" | 提交时触发校验。 |
const form = useForm({
defaultValues: {
title: "",
description: "",
},
validators: {
onSubmit: formSchema,
onChange: formSchema,
onBlur: formSchema,
},
})显示错误
使用 <FieldError /> 在字段旁边显示错误。为了样式和可访问性:
- 给
<Field />组件添加data-invalid属性。 - 给表单控件添加
aria-invalid属性,例如<Input />、<SelectTrigger />、<Checkbox />等。
<form.Field
name="email"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
type="email"
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
)
}}
/>处理不同字段类型
输入框
- 对于输入框字段,在
<Input />组件上使用field.state.value和field.handleChange。 - 要显示错误,请给
<Input />组件添加aria-invalid属性,并给<Field />组件添加data-invalid属性。
"use client"
import { useForm } from "@tanstack/react-form"<form.Field
name="username"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="form-tanstack-input-username">Username</FieldLabel>
<Input
id="form-tanstack-input-username"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="shadcn"
autoComplete="username"
/>
<FieldDescription>
This is your public display name. Must be between 3 and 10 characters.
Must only contain letters, numbers, and underscores.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
)
}}
/>文本域
- 对于文本域字段,在
<Textarea />组件上使用field.state.value和field.handleChange。 - 要显示错误,请给
<Textarea />组件添加aria-invalid属性,并给<Field />组件添加data-invalid属性。
"use client"
import { useForm } from "@tanstack/react-form"<form.Field
name="about"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor="form-tanstack-textarea-about">
More about you
</FieldLabel>
<Textarea
id="form-tanstack-textarea-about"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="I'm a software engineer..."
className="min-h-30"
/>
<FieldDescription>
Tell us more about yourself. This will be used to help us personalize
your experience.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
)
}}
/>选择器
- 对于选择器组件,在
<Select />组件上使用field.state.value和field.handleChange。 - 要显示错误,请给
<SelectTrigger />组件添加aria-invalid属性,并给<Field />组件添加data-invalid属性。
"use client"
import { useForm } from "@tanstack/react-form"<form.Field
name="language"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field orientation="responsive" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-tanstack-select-language">
Spoken Language
</FieldLabel>
<FieldDescription>
For best results, select the language you speak.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldContent>
<Select
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
<SelectTrigger
id="form-tanstack-select-language"
aria-invalid={isInvalid}
className="min-w-30"
>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
</Field>
)
}}
/>复选框
- 对于复选框,使用
<Checkbox />组件上的field.state.value和field.handleChange。 - 要显示错误,请给
<Checkbox />组件添加aria-invalid属性,并给<Field />组件添加data-invalid属性。 - 对于复选框数组,请在
<form.Field />组件上使用mode="array",并配合 TanStack Form 的数组辅助方法。 - 记得给
<FieldGroup />组件添加data-slot="checkbox-group",以便获得正确的样式和间距。
"use client"
import { useForm } from "@tanstack/react-form"<form.Field
name="tasks"
mode="array"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
return (
<FieldSet>
<FieldLegend variant="label">Tasks</FieldLegend>
<FieldDescription>
Get notified when tasks you've created have updates.
</FieldDescription>
<FieldGroup data-slot="checkbox-group">
{tasks.map((task) => (
<Field
key={task.id}
orientation="horizontal"
data-invalid={isInvalid}
>
<Checkbox
id={`form-tanstack-checkbox-${task.id}`}
name={field.name}
aria-invalid={isInvalid}
checked={field.state.value.includes(task.id)}
onCheckedChange={(checked) => {
if (checked) {
field.pushValue(task.id)
} else {
const index = field.state.value.indexOf(task.id)
if (index > -1) {
field.removeValue(index)
}
}
}}
/>
<FieldLabel
htmlFor={`form-tanstack-checkbox-${task.id}`}
className="font-normal"
>
{task.label}
</FieldLabel>
</Field>
))}
</FieldGroup>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldSet>
)
}}
/>单选组
- 对于单选组,使用
<RadioGroup />组件上的field.state.value和field.handleChange。 - 要显示错误,请给
<RadioGroupItem />组件添加aria-invalid属性,并给<Field />组件添加data-invalid属性。
"use client"
import { useForm } from "@tanstack/react-form"<form.Field
name="plan"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
return (
<FieldSet>
<FieldLegend>Plan</FieldLegend>
<FieldDescription>
You can upgrade or downgrade your plan at any time.
</FieldDescription>
<RadioGroup
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
{plans.map((plan) => (
<FieldLabel
key={plan.id}
htmlFor={`form-tanstack-radiogroup-${plan.id}`}
>
<Field orientation="horizontal" data-invalid={isInvalid}>
<FieldContent>
<FieldTitle>{plan.title}</FieldTitle>
<FieldDescription>{plan.description}</FieldDescription>
</FieldContent>
<RadioGroupItem
value={plan.id}
id={`form-tanstack-radiogroup-${plan.id}`}
aria-invalid={isInvalid}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldSet>
)
}}
/>开关
- 对于开关,使用
<Switch />组件上的field.state.value和field.handleChange。 - 要显示错误,请给
<Switch />组件添加aria-invalid属性,并给<Field />组件添加data-invalid属性。
"use client"
import { useForm } from "@tanstack/react-form"<form.Field
name="twoFactor"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
return (
<Field orientation="horizontal" data-invalid={isInvalid}>
<FieldContent>
<FieldLabel htmlFor="form-tanstack-switch-twoFactor">
Multi-factor authentication
</FieldLabel>
<FieldDescription>
Enable multi-factor authentication to secure your account.
</FieldDescription>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</FieldContent>
<Switch
id="form-tanstack-switch-twoFactor"
name={field.name}
checked={field.state.value}
onCheckedChange={field.handleChange}
aria-invalid={isInvalid}
/>
</Field>
)
}}
/>复杂表单
下面是一个包含多个字段和校验的更复杂表单示例。
"use client"
import * as React from "react"重置表单
使用 form.reset() 将表单重置为默认值。
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>数组字段
TanStack Form 通过 mode="array" 提供强大的数组字段管理能力。它支持在完整校验的前提下动态添加、删除和更新数组项。
"use client"
import * as React from "react"这个示例演示了如何使用数组字段管理多个邮箱地址。用户最多可以添加 5 个邮箱地址,可以删除单个地址,并且每个地址都会独立校验。
数组字段结构
在父字段上使用 mode="array" 以启用数组字段管理。
<form.Field
name="emails"
mode="array"
children={(field) => {
return (
<FieldSet>
<FieldLegend variant="label">Email Addresses</FieldLegend>
<FieldDescription>
Add up to 5 email addresses where we can contact you.
</FieldDescription>
<FieldGroup>
{field.state.value.map((_, index) => (
// Nested field for each array item
))}
</FieldGroup>
</FieldSet>
)
}}
/>嵌套字段
使用方括号语法访问单个数组项:fieldName[index].propertyName。这个示例使用 InputGroup 将删除按钮与输入框并排显示。
<form.Field
name={`emails[${index}].address`}
children={(subField) => {
const isSubFieldInvalid =
subField.state.meta.isTouched && !subField.state.meta.isValid
return (
<Field orientation="horizontal" data-invalid={isSubFieldInvalid}>
<FieldContent>
<InputGroup>
<InputGroupInput
id={`form-tanstack-array-email-${index}`}
name={subField.name}
value={subField.state.value}
onBlur={subField.handleBlur}
onChange={(e) => subField.handleChange(e.target.value)}
aria-invalid={isSubFieldInvalid}
placeholder="name@example.com"
type="email"
/>
{field.state.value.length > 1 && (
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
variant="ghost"
size="icon-xs"
onClick={() => field.removeValue(index)}
aria-label={`Remove email ${index + 1}`}
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
{isSubFieldInvalid && (
<FieldError errors={subField.state.meta.errors} />
)}
</FieldContent>
</Field>
)
}}
/>添加项目
使用 field.pushValue(item) 向数组字段添加项目。当数组达到最大长度时,你可以禁用按钮。
<Button
type="button"
variant="outline"
size="sm"
onClick={() => field.pushValue({ address: "" })}
disabled={field.state.value.length >= 5}
>
添加邮箱地址
</Button>删除项目
使用 field.removeValue(index) 从数组字段中删除项目。你也可以只在项目多于一个时显示删除按钮。
{
field.state.value.length > 1 && (
<InputGroupButton
onClick={() => field.removeValue(index)}
aria-label={`Remove email ${index + 1}`}
>
<XIcon />
</InputGroupButton>
)
}数组校验
使用 Zod 的 array 方法校验数组字段。
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."),
})