TanStack Form
Build forms in React using TanStack Form and Zod.
This guide explores how to build forms using TanStack Form. You'll learn to create forms with the <Field /> component, implement schema validation with Zod, handle errors, and ensure accessibility.
Demo
We'll start by building the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.
Note: For the purpose of this demo, we have intentionally disabled browser validation to show how schema validation and form errors work in TanStack Form. It is recommended to add basic browser validation in your production code.
Approach
This form leverages TanStack Form for powerful, headless form handling. We'll build our form using the <Field /> component, which gives you complete flexibility over the markup and styling.
- Uses TanStack Form's
useFormhook for form state management. form.Fieldcomponent with render prop pattern for controlled inputs.<Field />components for building accessible forms.- Client-side validation using Zod.
- Real-time validation feedback.
Anatomy
Here's a basic example of a form using TanStack Form with the <Field /> component.
<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><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>Form
Create a schema
We'll start by defining the shape of our form using a Zod 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."),
})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."),
})Setup the form
Use the useForm hook from TanStack Form to create your form instance with Zod validation.
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("Form submitted successfully")
},
})
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
{/* ... */}
</form>
)
}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("Form submitted successfully")
},
})
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
{/* ... */}
</form>
)
}We are using onSubmit to validate the form data here. TanStack Form supports other validation modes, which you can read about in the documentation.
Build the form
We can now build the form using the form.Field component from TanStack Form and the <Field /> component.
Done
That's it. You now have a fully accessible form with client-side validation.
When you submit the form, the onSubmit function will be called with the validated form data. If the form data is invalid, TanStack Form will display the errors next to each field.
Validation
Client-side Validation
TanStack Form validates your form data using the Zod schema. Validation happens in real-time as the user types.
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>
}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>
}Validation Modes
TanStack Form supports different validation strategies through the validators option:
| Mode | Description |
|---|---|
"onChange" | Validation triggers on every change. |
"onBlur" | Validation triggers on blur. |
"onSubmit" | Validation triggers on submit. |
const form = useForm({
defaultValues: {
title: "",
description: "",
},
validators: {
onSubmit: formSchema,
onChange: formSchema,
onBlur: formSchema,
},
})const form = useForm({
defaultValues: {
title: "",
description: "",
},
validators: {
onSubmit: formSchema,
onChange: formSchema,
onBlur: formSchema,
},
})Displaying Errors
Display errors next to the field using <FieldError />. For styling and accessibility:
- Add the
data-invalidprop to the<Field />component. - Add the
aria-invalidprop to the form control such as<Input />,<SelectTrigger />,<Checkbox />, etc.
<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>
)
}}
/><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>
)
}}
/>Working with Different Field Types
Input
- For input fields, use
field.state.valueandfield.handleChangeon the<Input />component. - To show errors, add the
aria-invalidprop to the<Input />component and thedata-invalidprop to the<Field />component.
Textarea
- For textarea fields, use
field.state.valueandfield.handleChangeon the<Textarea />component. - To show errors, add the
aria-invalidprop to the<Textarea />component and thedata-invalidprop to the<Field />component.
Select
- For select components, use
field.state.valueandfield.handleChangeon the<Select />component. - To show errors, add the
aria-invalidprop to the<SelectTrigger />component and thedata-invalidprop to the<Field />component.
Checkbox
- For checkbox, use
field.state.valueandfield.handleChangeon the<Checkbox />component. - To show errors, add the
aria-invalidprop to the<Checkbox />component and thedata-invalidprop to the<Field />component. - For checkbox arrays, use
mode="array"on the<form.Field />component and TanStack Form's array helpers. - Remember to add
data-slot="checkbox-group"to the<FieldGroup />component for proper styling and spacing.
Radio Group
- For radio groups, use
field.state.valueandfield.handleChangeon the<RadioGroup />component. - To show errors, add the
aria-invalidprop to the<RadioGroupItem />component and thedata-invalidprop to the<Field />component.
Switch
- For switches, use
field.state.valueandfield.handleChangeon the<Switch />component. - To show errors, add the
aria-invalidprop to the<Switch />component and thedata-invalidprop to the<Field />component.
Complex Forms
Here is an example of a more complex form with multiple fields and validation.
Resetting the Form
Use form.reset() to reset the form to its default values.
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button><Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>Array Fields
TanStack Form provides powerful array field management with mode="array". This allows you to dynamically add, remove, and update array items with full validation support.
Array Field Structure
Use mode="array" on the parent field to enable array field management.
<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>
)
}}
/><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>
)
}}
/>Adding Items
Use field.pushValue(item) to add items to an array field. You can disable the button when the array reaches its maximum length.
<Button
type="button"
variant="outline"
size="sm"
onClick={() => field.pushValue({ address: "" })}
disabled={field.state.value.length >= 5}
>
Add Email Address
</Button><Button
type="button"
variant="outline"
size="sm"
onClick={() => field.pushValue({ address: "" })}
disabled={field.state.value.length >= 5}
>
Add Email Address
</Button>Removing Items
Use field.removeValue(index) to remove items from an array field. You can conditionally show the remove button only when there's more than one item.
{field.state.value.length > 1 && (
<InputGroupButton
onClick={() => field.removeValue(index)}
aria-label={`Remove email ${index + 1}`}
>
<XIcon />
</InputGroupButton>
)}{field.state.value.length > 1 && (
<InputGroupButton
onClick={() => field.removeValue(index)}
aria-label={`Remove email ${index + 1}`}
>
<XIcon />
</InputGroupButton>
)}Array Validation
Validate array fields using Zod's array methods.
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."),
})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."),
})