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>
Form
component provides the form contextform.handleSubmit
connects 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 button
install 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})
zodResolver
converts Zod schema to RHF formatdefaultValues
initializes 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.isSubmitting
tracks 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