- Accordion
- Action BarUpdated
- Alert Dialog
- Alert
- Announcement
- Aspect Ratio
- Autocomplete
- Avatar
- BadgeUpdated
- Bottom Navigation
- Breadcrumb
- Button Group
- Button
- CalendarUpdated
- CardUpdated
- Carousel
- Chart
- Checkbox
- Circular Progress
- Circular Slider
- Clipboard
- Collapsible
- Color Picker
- Combobox
- Command
- Context MenuUpdated
- Data List
- Date Picker
- DialogUpdated
- DrawerUpdated
- Editable
- FieldUpdated
- File Upload
- Float
- Floating Panel
- Frame
- Hint
- Hover Card
- Image Cropper
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Link Overlay
- Listbox
- MarqueeUpdated
- Menu
- Native Select
- Number InputUpdated
- Pagination
- Popover
- Progress
- Prose
- QR Code
- Radio Group
- Rating
- Resizable
- Scroll Area
- Segment Group
- Select
- Separator
- Sheet
- Sidebar
- Signature Pad
- Skeleton
- Skip Nav
- Slider
- Spinner
- Status
- Steps
- Switch
- TableUpdated
- Tabs
- Textarea
- TimerUpdated
- ToastUpdated
- Toggle Group
- Toggle Tooltip
- Toggle
- Tooltip
- Tour
- Tree View
This guide will cover building forms using the Field component, adding schema validation with Zod, handling errors, ensuring accessibility, and more.
Demo#
We’ll build a form with a text input and textarea. When you submit, the form data is validated and any errors will be shown.
For the purposes of this demo, browser validation is disabled to illustrate schema validation. In production, keep native validation enabled when appropriate.
Approach#
This form uses TanStack Form for state and Zod for validation. We'll build forms using the Field component, which gives you complete flexibility over the markup and styling.
- Uses TanStack Form's
useFormhook for form state management. - Uses
form.Fieldwith a render function for controlled inputs. - Uses the
Fieldcomponents for building accessible forms. - Uses client-side validation by passing your Zod schema into
validators.
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) => ( <Field invalid={!field.state.meta.isValid}> <FieldLabel>Bug Title</FieldLabel> <Input name={field.name} value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} placeholder="Login button not working on mobile" autoComplete="off" /> <FieldDescription> Provide a concise title for your bug report. </FieldDescription> <FieldError> {field.state.meta.errors .map((e) => e?.message || e) .join(", ")} </FieldError> </Field> )} /> </FieldGroup> <Button type="submit">Submit</Button> </form>
Form#
Create a schema#
Define your form shape with a Zod schema.
Note: TanStack Form works with Zod and other Standard Schema libraries via its validators API.
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#
- Create the form with
useFormfrom TanStack Form and pass your schema to theonSubmitoption. - Use e.preventDefault() and e.stopPropagation() before calling form.handleSubmit()
import { useForm } from "@tanstack/react-form"; import * as z from "zod"; const formSchema = z.object({ // ... }); export const ExampleForm = () => { const form = useForm({ defaultValues: { title: "", description: "", }, validators: { onSubmit: formSchema }, onSubmit: async ({ value }) => { console.log(value); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); e.stopPropagation() form.handleSubmit(); }} > {/* Build the form here */} </form> ); };
Build#
build the form using the form.Field from TanStack Form and the Shark Field.
"use client";
import { toast } from "@registry/react/components/toast";
import { useForm } from "@tanstack/react-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} 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 const BugReportForm = () => {
const form = useForm({
defaultValues: {
title: "",
description: "",
},
validators: {
onSubmit: formSchema,
},
onSubmit: ({ value }) => {
toast.info({
id: "bug-report-submitted",
title: "Bug submitted",
description: (
<pre className="mt-2">
<code>{JSON.stringify(value, null, 2)}</code>
</pre>
),
});
},
});
return (
<Card asChild className="w-full sm:max-w-md">
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<CardHeader
description="Help us improve by reporting bugs you encounter."
title="Bug Report"
/>
<CardContent>
<FieldGroup>
<form.Field
children={(field) => (
<Field invalid={!field.state.meta.isValid}>
<FieldLabel>Bug Title</FieldLabel>
<Input
autoComplete="off"
name={field.name}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Login button not working on mobile"
value={field.state.value}
/>
<FieldError>
{field.state.meta.errors.map((e) => e?.message).join(", ")}
</FieldError>
</Field>
)}
name="title"
/>
<form.Field
children={(field) => (
<Field invalid={!field.state.meta.isValid}>
<FieldLabel>Description</FieldLabel>
<InputGroup>
<InputGroupTextarea
className="min-h-24 resize-none"
name={field.name}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="I'm having an issue with the login button on mobile."
rows={6}
value={field.state.value}
/>
<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>
<FieldError>
{field.state.meta.errors.map((e) => e?.message).join(", ")}
</FieldError>
</Field>
)}
name="description"
/>
</FieldGroup>
</CardContent>
<CardFooter>
<Button onClick={() => form.reset()} variant="outline">
Reset
</Button>
<Button type="submit">Submit</Button>
</CardFooter>
</form>
</Card>
);
};Done#
That's it. You now have a fully accessible form with client-side validation.
When you submit the form, the onSubmit handler receives validated values. If the form is invalid, TanStack Form exposes errors on field.state.meta.errors for FieldError.
Validation#
Client-side#
TanStack Form validates your form data using the Zod schema. Define a schema and pass it to the validators option of the useForm hook.
import { useForm } from "@tanstack/react-form" import * as z from "zod" const formSchema = z.object({ title: z.string(), description: z.string().optional(), }) export const ExampleForm = () => { const form = useForm({ defaultValues: { title: "", description: "", }, validators: { onSubmit: formSchema }, onSubmit: async () => {}, }) }
Modes#
Configure when validation runs via the validators option:
const form = useForm({ validators: { onSubmit: formSchema }, })
| Mode | Description |
|---|---|
"onChange" | Validation triggers on every change. |
"onBlur" | Validation triggers on blur. |
"onSubmit" | Validation triggers on submit. |
Displaying Errors#
Display errors next to the field using FieldError. For styling and accessibility:
- Add the
invalidprop to theFieldcomponent. - Don't need to add the
invalidprop to the form control such asInput,Checkbox, etc.
<form.Field name="email" children={(field) => ( <Field invalid={!field.state.meta.isValid}> <FieldLabel>Email</FieldLabel> <Input name={field.name} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} type="email" value={field.state.value} /> <FieldError> {field.state.meta.errors.map((e) => e?.message).join(", ")} </FieldError> </Field> )} />
Different types of fields#
Input#
- Bind
field.state.valueandfield.handleChangetoInput. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Textarea#
- Bind
field.state.valueandfield.handleChangetoTextarea. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
NativeSelect#
- Bind
field.state.valueandfield.handleChangetoNativeSelect. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Select#
- Wire
valueandonValueChangeonSelect. For overlays, callfield.handleBlur()fromonInteractOutsidewhen your example needs blur sync. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Checkbox#
- Wire
field.state.valueandfield.handleChangetoCheckbox. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent. - For checkbox arrays, use
mode="array"on theform.Fieldcomponent and TanStack Form's array helpers. - Remember to add
data-slot="checkbox-group"to theFieldGroupcomponent for proper styling and spacing.
Radio group#
- Wire
field.state.valueandfield.handleChangetoRadioGroup. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
Switch#
- Use
field.state.valueandfield.handleChangewithSwitch. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
NumberInput#
- Wire
field.state.valueandfield.handleChangetoNumberInput. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
Slider#
- Wire
field.state.valueandfield.handleChangetoSlider. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
Combobox#
- Wire
field.state.valueandfield.handleChangetoCombobox. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
Autocomplete#
- Wire
field.state.valueandfield.handleChangetoAutocomplete. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
Date Picker#
- Wire
field.state.valueandfield.handleChangetoDatePicker. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
Input OTP#
- Use
field.state.value(asstring[]) andfield.handleChangewithInputOTP. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
Rating#
- Wire
field.state.valueandfield.handleChangetoRating. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
File Upload#
- Wire
onFileAcceptto sync files into form state. - Add the
invalidprop to theFieldcomponent and pass theFieldErrorcomponent.
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>
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) => ( <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> )} />
Nested Fields#
Access individual array items using bracket notation: fieldName[index].propertyName.
<form.Field name={`emails[${index}].address`} children={(subField) => ( <Field orientation="horizontal" invalid={!subField.state.meta.isValid}> <FieldContent> <InputGroup> <InputGroupInput name={subField.name} value={subField.state.value} onBlur={subField.handleBlur} onChange={(e) => subField.handleChange(e.target.value)} 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> <FieldError> {subField.state.meta.errors.map((e) => e?.message).join(", ")} </FieldError> </FieldContent> </Field> )} />
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>
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> ) }
Removing items#
Use removeValue(index) on the array field.
{ emailsField.state.value.length > 1 && ( <InputGroupAddon align="inline-end"> <InputGroupButton type="button" variant="ghost" size="icon-xs" onClick={() => emailsField.removeValue(index)} aria-label={`Remove email ${index + 1}`} > <XIcon /> </InputGroupButton> </InputGroupAddon> ); }
Array validation#
Validate arrays with Zod’s z.array() and .min() / .max().
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."), });
On This Page