Leon

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 context
  • form.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 object
  • name: Matches the Zod schema field name
  • render: 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 format
  • defaultValues 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