- 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 React Hook 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 React Hook Form's useForm hook for form state management.
- Uses the
Controllercomponent for controlled inputs. - Uses the
Fieldcomponents for building accessible forms. - Uses client-side validation by passing your Zod schema into
resolver.
Anatomy#
Typical structure: wrap each field with Controller, and the Field component.
<form onSubmit={form.handleSubmit(onSubmit)}> <FieldGroup> <Controller name="title" control={form.control} render={({ field, fieldState }) => ( <Field invalid={fieldState.invalid}> <FieldLabel>Bug Title</FieldLabel> <Input {...field} placeholder="Login button not working on mobile" autoComplete="off" /> <FieldDescription> Provide a concise title for your bug report. </FieldDescription> <FieldError>{fieldState.error?.message}</FieldError> </Field> )} /> </FieldGroup> <Button type="submit">Submit</Button> </form>
Form#
Create a schema#
Define your form shape with a Zod schema.
Note: React Hook Form supports other Standard Schema libraries; Zod is used here for clarity.
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 useForm from React Hook Form and pass your schema to the resolver option.
import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import * as z from "zod"; const formSchema = z.object({ // ... }); export const BugReportForm = () => { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { title: "", description: "", }, }); const onSubmit = (data: z.infer<typeof formSchema>) => { console.log(data); } return ( <form onSubmit={form.handleSubmit(onSubmit)}> {/* Build the form here */} </form> ); };
Build#
Build the form using the Controller from React Hook Form and the Shark Field.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@registry/react/components/toast";
import { Controller, useForm } from "react-hook-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({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
});
const onSubmit = (data: z.infer<typeof formSchema>) => {
toast.info({
id: "bug-report-submitted",
title: "Bug submitted",
description: (
<pre className="mt-2">
<code>{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
};
return (
<Card asChild className="w-full sm:max-w-md">
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardHeader
description="Help us improve by reporting bugs you encounter."
title="Bug Report"
/>
<CardContent>
<FieldGroup>
<Controller
control={form.control}
name="title"
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid}>
<FieldLabel>Bug Title</FieldLabel>
<Input
{...field}
autoComplete="off"
placeholder="Login button not working on mobile"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<Field invalid={fieldState.invalid}>
<FieldLabel>Description</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
className="min-h-24 resize-none"
placeholder="I'm having an issue with the login button on mobile."
rows={6}
/>
<InputGroupAddon align="block-end">
<InputGroupText className="tabular-nums">
{field.value.length}/100 characters
</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FieldDescription>
Include steps to reproduce, expected behavior, and what
actually happened.
</FieldDescription>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</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 function will be called with the validated form data. If the form is invalid, React Hook Form will display the errors on field.state.error for FieldError.
Validation#
Client-side#
React Hook Form validates your form data using the Zod schema. Define a schema and pass it to the resolver option of the useForm hook.
import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import * as z from "zod" const formSchema = z.object({ title: z.string(), description: z.string().optional(), }) export const ExampleForm = () => { const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { title: "", description: "", }, }) }
Modes#
Configure when validation runs via the mode option:
const form = useForm({ resolver: zodResolver(formSchema), mode: "onChange", })
| Mode | Description |
|---|---|
"onChange" | Validation triggers on every change. |
"onBlur" | Validation triggers on blur. |
"onSubmit" | Validation triggers on submit (default). |
"onTouched" | Validation triggers on first blur, then on every change. |
"all" | Validation triggers on blur and change. |
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.
<Controller name="email" control={form.control} render={({ field, fieldState }) => ( <Field invalid={fieldState.invalid}> <FieldLabel>Email</FieldLabel> <Input {...field} type="email" /> <FieldError>{fieldState.error?.message}</FieldError> </Field> )} />
Different types of fields#
Input#
- Spread the field object onto the
Inputcomponent. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Textarea#
- Spread the field object onto the
Textareacomponent. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
NativeSelect#
- Spread the field object onto the
NativeSelectcomponent. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Select#
- Wire
field.valueandfield.onChangetoSelect. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Checkbox#
- Wire
field.valueandfield.onChangetoCheckbox. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent. - Remember to add
data-slot="checkbox-group"to theFieldGroupcomponent for proper styling and spacing.
Radio group#
- Wire
field.valueandfield.onChangetoRadioGroup. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Switch#
- Wire
field.valueandfield.onChangetoSwitch. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
NumberInput#
- Wire
field.valueandfield.onChangetoNumberInput. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Slider#
- Wire
field.valueandfield.onChangetoSlider. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Combobox#
- Wire
field.valueandfield.onChangetoCombobox. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Autocomplete#
- Wire
field.valueandfield.onChangetoAutocomplete. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Date Picker#
- Wire
field.valueandfield.onChangetoDatePicker. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Input OTP#
- Wire
field.valueandfield.onChangetoInputOTP. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
Rating#
- Wire
field.valueandfield.onChangetoRating. - Add the
invalidprop to theFieldcomponent and pass the error message to theFieldErrorcomponent.
File Upload#
- Wire
field.valueandfield.onChangetoFileUpload. - Add the
invalidprop to theFieldcomponent and pass the error message to 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#
React Hook Form provides a useFieldArray hook for managing dynamic array fields. This is useful when you need to add or remove fields dynamically.
Using useFieldArray#
Use the useFieldArray hook to manage array fields. It provides fields, append, and remove methods.
import { useFieldArray, useForm } from "react-hook-form"; export const ExampleForm = () => { const form = useForm({ // ... form config }); const { fields, append, remove } = useFieldArray({ control: form.control, name: "emails", }); }
Array Field Structure#
Wrap your array fields in a FieldSet with a FieldLegend and FieldDescription.
<FieldSet className="gap-4"> <FieldLegend variant="label">Email Addresses</FieldLegend> <FieldDescription> Add up to 5 email addresses where we can contact you. </FieldDescription> <FieldGroup className="gap-4">{/* Array items go here */}</FieldGroup> </FieldSet>
Controller Pattern for Array Items#
Map over the fields array and use Controller for each item. Make sure to use field.id as the key.
{ fields.map((field, index) => ( <Controller key={field.id} name={`emails.${index}.address`} control={form.control} render={({ field: controllerField, fieldState }) => ( <Field invalid={fieldState.invalid} orientation="horizontal"> <FieldContent> <InputGroup> <InputGroupInput {...controllerField} id={`form-rhf-array-email-${index}`} aria-invalid={fieldState.invalid} placeholder="name@example.com" type="email" autoComplete="email" /> {/* Remove button */} </InputGroup> <FieldError>{fieldState.error?.message}</FieldError> </FieldContent> </Field> )} /> ))}
Adding Items#
Use the append method to add new items to the array.
<Button type="button" variant="outline" size="sm" onClick={() => append({ address: "" })} disabled={fields.length >= 5} > Add Email Address </Button>
Removing Items#
Use the remove method to remove items from the array. Add the remove button conditionally.
{ fields.length > 1 && ( <InputGroupAddon align="inline-end"> <InputGroupButton type="button" variant="ghost" size="icon-xs" onClick={() => remove(index)} aria-label={`Remove email ${index + 1}`} > <XIcon /> </InputGroupButton> </InputGroupAddon> )} }
Array Validation#
Use Zod's array method to validate array fields.
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