Comprehensive example of a Shadcn Form with Zod validation
Topics
React
Published on 
17 Jan 2025
1import { useForm } from "react-hook-form"
2import { zodResolver } from "@hookform/resolvers/zod"
3import * as z from "zod"
4import {
5  Form,
6  FormControl,
7  FormField,
8  FormItem,
9  FormLabel,
10  FormMessage,
11} from "@/components/ui/form"
12import { Input } from "@/components/ui/input"
13import { Button } from "@/components/ui/button"
14import { Textarea } from "@/components/ui/textarea"
15import {
16  Select,
17  SelectContent,
18  SelectItem,
19  SelectTrigger,
20  SelectValue,
21} from "@/components/ui/select"
22import { Checkbox } from "@/components/ui/checkbox"
23import { Loader2 } from "lucide-react"
24
25// 1. Define Zod validation schema
26const formSchema = z.object({
27  username: z.string()
28    .min(2, "Username must be at least 2 characters")
29    .max(50, "Username too long"),
30  email: z.string().email("Invalid email address"),
31  bio: z.string()
32    .min(10, "Bio must be at least 10 characters")
33    .max(200, "Bio too long"),
34  age: z.coerce.number()
35    .min(18, "Must be at least 18 years old")
36    .max(100, "Invalid age"),
37  newsletter: z.boolean(),
38  country: z.string().min(1, "Please select a country"),
39})
40
41export function UserProfileForm() {
42  // 2. Initialize form with React Hook Form and Zod
43  const form = useForm<z.infer<typeof formSchema>>({
44    resolver: zodResolver(formSchema),
45    defaultValues: {
46      username: "",
47      email: "",
48      bio: "",
49      age: 18,
50      newsletter: false,
51      country: "",
52    },
53  })
54
55  // 3. Handle form submission
56  const onSubmit = async (values: z.infer<typeof formSchema>) => {
57    // Simulate API call
58    await new Promise(resolve => setTimeout(resolve, 2000))
59    console.log("Form submitted:", values)
60  }
61
62  return (
63    <Form {...form}>
64      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
65        {/* Text Input */}
66        <FormField
67          control={form.control}
68          name="username"
69          render={({ field }) => (
70            <FormItem>
71              <FormLabel>Username</FormLabel>
72              <FormControl>
73                <Input 
74                  placeholder="Enter username" 
75                  {...field} 
76                  disabled={form.formState.isSubmitting}
77                />
78              </FormControl>
79              <FormMessage />
80            </FormItem>
81          )}
82        />
83
84        {/* Email Input */}
85        <FormField
86          control={form.control}
87          name="email"
88          render={({ field }) => (
89            <FormItem>
90              <FormLabel>Email</FormLabel>
91              <FormControl>
92                <Input
93                  type="email"
94                  placeholder="email@example.com"
95                  {...field}
96                  disabled={form.formState.isSubmitting}
97                />
98              </FormControl>
99              <FormMessage />
100            </FormItem>
101          )}
102        />
103
104        {/* Textarea */}
105        <FormField
106          control={form.control}
107          name="bio"
108          render={({ field }) => (
109            <FormItem>
110              <FormLabel>Bio</FormLabel>
111              <FormControl>
112                <Textarea
113                  placeholder="Tell us about yourself"
114                  {...field}
115                  disabled={form.formState.isSubmitting}
116                />
117              </FormControl>
118              <FormMessage />
119            </FormItem>
120          )}
121        />
122
123        {/* Number Input */}
124        <FormField
125          control={form.control}
126          name="age"
127          render={({ field }) => (
128            <FormItem>
129              <FormLabel>Age</FormLabel>
130              <FormControl>
131                <Input
132                  type="number"
133                  {...field}
134                  disabled={form.formState.isSubmitting}
135                />
136              </FormControl>
137              <FormMessage />
138            </FormItem>
139          )}
140        />
141
142        {/* Select Dropdown */}
143        <FormField
144          control={form.control}
145          name="country"
146          render={({ field }) => (
147            <FormItem>
148              <FormLabel>Country</FormLabel>
149              <Select
150                onValueChange={field.onChange}
151                value={field.value}
152                disabled={form.formState.isSubmitting}
153              >
154                <FormControl>
155                  <SelectTrigger>
156                    <SelectValue placeholder="Select a country" />
157                  </SelectTrigger>
158                </FormControl>
159                <SelectContent>
160                  <SelectItem value="us">United States</SelectItem>
161                  <SelectItem value="ca">Canada</SelectItem>
162                  <SelectItem value="uk">United Kingdom</SelectItem>
163                </SelectContent>
164              </Select>
165              <FormMessage />
166            </FormItem>
167          )}
168        />
169
170        {/* Checkbox */}
171        <FormField
172          control={form.control}
173          name="newsletter"
174          render={({ field }) => (
175            <FormItem className="flex flex-row items-start space-x-3 space-y-0">
176              <FormControl>
177                <Checkbox
178                  checked={field.value}
179                  onCheckedChange={field.onChange}
180                  disabled={form.formState.isSubmitting}
181                />
182              </FormControl>
183              <div className="space-y-1 leading-none">
184                <FormLabel>Subscribe to newsletter</FormLabel>
185              </div>
186              <FormMessage />
187            </FormItem>
188          )}
189        />
190
191        {/* Submit Button with Loading State */}
192        <Button 
193          type="submit"
194          disabled={form.formState.isSubmitting}
195        >
196          {form.formState.isSubmitting ? (
197            <>
198              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
199              Submitting...
200            </>
201          ) : (
202            "Submit"
203          )}
204        </Button>
205      </form>
206    </Form>
207  )
208}Components breakdown of <Form> and its children components
1. Form Structure
1<Form {...form}>
2  <form onSubmit={form.handleSubmit(onSubmit)}>
3    {/* Form fields */}
4  </form>
5</Form>Formcomponent provides the form contextform.handleSubmitconnects to RHF's submission handler{...form}spreads the form context to all children
2. FormField Component
1<FormField
2  control={form.control}
3  name="fieldName"
4  render={({ field }) => (
5    // Field components
6  )}
7/>control: Connects to RHF's control objectname: Matches the Zod schema field namerender: Function that returns the input components
3. FormField Render Props
The render function receives an object with:
field: Contains input props (value, onChange, etc.)fieldState: Contains validation state (error, isDirty, etc.)
1{
2  field: {
3    value: any,        // Current field value
4    onChange: () => void,  // Value change handler
5    onBlur: () => void,    // Blur handler
6    ref: React.Ref,    // Input reference
7    name: string,      // Field name
8    disabled: boolean  // Disabled state
9  },
10  fieldState: {
11    error: { message: string },  // Validation error
12    isTouched: boolean,
13    isDirty: boolean
14  }
15}4. FormControl Component
1<FormControl>
2  <Input {...field} />
3</FormControl>- Wraps input components
 - Manages focus states and accessibility
 - Spread 
{...field}connects the input to RHF 
5. FormLabel Component
1<FormLabel>Email</FormLabel>- Provides accessible labels
 - Automatically associates with input
 
6. FormMessage Component
1<FormMessage />- Automatically displays validation errors
 - Pulls error messages from Zod schema
 
Setup
1npx shadcn-ui@latest add form input textarea select checkbox buttoninstall Shadcn Form will also include react-hook-form and zod.
Validation Workflow:
1. Zod Schema Definition
1const formSchema = z.object({/* validation rules */})- Defines all validation rules in one place
 - Provides TypeScript type inference
 
2. Form Initialization
1const form = useForm<z.infer<typeof formSchema>>({
2  resolver: zodResolver(formSchema),
3  defaultValues: {/* initial values */}
4})zodResolverconverts Zod schema to RHF formatdefaultValuesinitializes form state
3. Error Handling
- Errors automatically populated from Zod validation
 <FormMessage />displays errors below each field
Submission State Management:
1disabled={form.formState.isSubmitting}form.formState.isSubmittingtracks submission state- Disables inputs and shows loading state during submission
 
Key Features:
1. Type Safety
- Zod schema provides end-to-end type safety
 z.coerce.number()automatically converts string inputs to numbers- TypeScript checks all form values and validations
 
2. Performance
- Uncontrolled inputs by default
 - Minimal re-renders during input changes
 
3. Accessibility
- Semantic HTML structure
 - Proper ARIA attributes
 - Accessible error messages
 
4. Customization
- Consistent styling through Tailwind CSS
 - Easy to modify validation rules
 - Flexible component structure
 
5. Error Handling:
- Automatic error message display
 - Custom error messages in Zod schema
 - Position-aware error messages with 
<FormMessage> 
6. Component Composition:
- Each input is wrapped in form-specific components
 - Consistent styling through Shadcn's pre-built components
 
Accessibility built into all components
Table of Contents