Skip to main content

Form Component

A comprehensive, accessible form handling component that wraps react-hook-form and provides a structured approach to building and validating forms in your application.

Import

import { 
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage
} from '@/components/ui/form';

Usage

The Form component works with react-hook-form to manage form state and validation. It integrates well with validation libraries like Zod.

Basic Usage

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"

import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"

const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
})

export function ProfileForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
})

function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter username" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}

Using with Multiple Fields and Different Input Types

const formSchema = z.object({
username: z.string().min(2).max(50),
email: z.string().email(),
bio: z.string().max(160).optional(),
notifications: z.boolean().default(false),
})

export function SettingsForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
email: "",
bio: "",
notifications: false,
},
})

function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values)
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea placeholder="Tell us about yourself" {...field} />
</FormControl>
<FormDescription>
Max 160 characters.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="notifications"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Enable notifications</FormLabel>
<FormDescription>
Receive email notifications for important updates.
</FormDescription>
</div>
</FormItem>
)}
/>

<Button type="submit">Save changes</Button>
</form>
</Form>
)
}

Components

The Form component is a collection of several sub-components that work together:

Form

The root component that wraps around react-hook-form's FormProvider.

FormField

A wrapper around react-hook-form's Controller that provides context for form fields.

FormItem

A container for individual form fields that provides proper spacing and layout.

FormLabel

A label component that's automatically associated with its corresponding form control.

FormControl

A component that provides the proper accessibility attributes to form inputs.

FormDescription

A description component for providing additional information about a form field.

FormMessage

A component for displaying validation error messages.

Props

Form

PropTypeDefaultDescription
...propsUseFormReturn<TFieldValues, TContext>-The return value from the useForm hook

FormField

PropTypeDefaultDescription
controlControl<TFieldValues>RequiredThe form control from react-hook-form
nameFieldPath<TFieldValues>RequiredThe name of the form field
render({ field, fieldState, formState }) => React.ReactElementRequiredA render function that provides access to the field props

FormItem

PropTypeDefaultDescription
classNamestring-Optional additional CSS classes
...propsHTMLAttributes<HTMLDivElement>-HTML div attributes

FormLabel

PropTypeDefaultDescription
classNamestring-Optional additional CSS classes
...propsComponentPropsWithoutRef<typeof LabelPrimitive.Root>-Radix UI Label props

FormControl

PropTypeDefaultDescription
...propsComponentPropsWithoutRef<typeof Slot>-Radix UI Slot props

FormDescription

PropTypeDefaultDescription
classNamestring-Optional additional CSS classes
...propsHTMLAttributes<HTMLParagraphElement>-HTML paragraph attributes

FormMessage

PropTypeDefaultDescription
classNamestring-Optional additional CSS classes
childrenReactNode-Optional content to display when there's no error
...propsHTMLAttributes<HTMLParagraphElement>-HTML paragraph attributes

TypeScript

// Form Field Context Type
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}

// Form Item Context Type
type FormItemContextValue = {
id: string
}

// useFormField Return Type
type FormFieldInfo = {
id: string;
name: string;
formItemId: string;
formDescriptionId: string;
formMessageId: string;
error?: FieldError;
isDirty: boolean;
isTouched: boolean;
}

Customization

Style Overrides

All form components accept a className prop that allows you to add additional Tailwind CSS classes:

<FormItem className="grid grid-cols-4 items-start gap-4">
<FormLabel className="mt-2 text-right">Username</FormLabel>
<div className="col-span-3">
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription className="text-xs">
Your public display name.
</FormDescription>
<FormMessage className="text-xs font-bold" />
</div>
</FormItem>

Creating Custom Form Controls

You can easily create custom form controls by wrapping them with FormControl:

<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount</FormLabel>
<FormControl>
<div className="relative">
<span className="absolute left-2 top-2">$</span>
<Input
className="pl-6"
type="number"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value))}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

Examples

Integration with Select Component

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"

<FormField
control={form.control}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>Country</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a country" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="us">United States</SelectItem>
<SelectItem value="ca">Canada</SelectItem>
<SelectItem value="uk">United Kingdom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>

Using with Supabase

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { useRouter } from "next/navigation"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"

import { Button } from "@/components/ui/button"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"

const formSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
})

export function LoginForm() {
const router = useRouter()
const supabase = createClientComponentClient()

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
})

async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const { error } = await supabase.auth.signInWithPassword({
email: values.email,
password: values.password,
})

if (error) {
form.setError("root", { message: error.message })
return
}

router.push("/dashboard")
router.refresh()
} catch (error) {
console.error(error)
form.setError("root", { message: "An unexpected error occurred" })
}
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className="text-sm font-medium text-destructive">
{form.formState.errors.root.message}
</p>
)}
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Logging in..." : "Log in"}
</Button>
</form>
</Form>
)
}

Responsive Behavior

The Form components are designed to be responsive by default and can be customized with Tailwind's responsive classes:

<FormItem className="sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<FormLabel className="sm:text-right sm:mt-1">Name</FormLabel>
<div className="sm:col-span-3 sm:mt-0 mt-1">
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</div>
</FormItem>

Accessibility

The Form components follow these accessibility best practices:

  • FormLabel is properly associated with its corresponding form control using the htmlFor attribute
  • FormControl adds appropriate ARIA attributes to form inputs (aria-describedby, aria-invalid)
  • Error messages are properly linked to inputs using IDs
  • Form descriptions are properly associated with inputs using IDs
  • Error states are properly indicated visually with color changes and programmatically with aria-invalid

Implementation Details

The Form component:

  • Uses React Context to provide communication between nested form components
  • Leverages react-hook-form for state management and validation
  • Uses Radix UI's Slot component to enable passing props to arbitrary components
  • Generates unique IDs automatically using React's useId hook
  • Handles error states and messages automatically

Common Pitfalls

  • Missing Control Prop: Always make sure to pass the form.control to FormField, otherwise the form will not be properly controlled.
  • Using Without Field Render Function: The render function is required for FormField to work properly.
  • Forgetting FormControl: Always wrap your input components with FormControl to ensure proper accessibility.
  • Not Providing Default Values: Since FormField uses a controlled component approach, you should provide default values for all fields in your form.
  • Validation Errors Not Showing: Make sure you've included the FormMessage component in your FormItem.

Testing

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

const formSchema = z.object({
username: z.string().min(2),
});

function TestForm() {
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
});

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(() => {})}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} data-testid="username-input" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}

describe('Form', () => {
it('renders form elements correctly', () => {
render(<TestForm />);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByTestId('username-input')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});

it('shows validation error for too short username', async () => {
render(<TestForm />);
const user = userEvent.setup();

await user.type(screen.getByTestId('username-input'), 'a');
await user.click(screen.getByRole('button', { name: /submit/i }));

// Wait for the validation error to appear
expect(await screen.findByText(/must be at least 2/i)).toBeInTheDocument();
});
});
  • Input: Text input component that works with FormControl
  • Select: Selection input component that works with FormControl
  • Checkbox: Toggle input component that works with FormControl
  • RadioGroup: Radio button group component that works with FormControl
  • Textarea: Multi-line text input component that works with FormControl
  • Button: Button component typically used for form submission