Architecture Overview
Forms in this project use a schema-first approach combining three layers:
- •Zod — defines the validation schema and infers TypeScript types
- •react-hook-form with
zodResolver— manages form state, validation, submission, and error tracking - •shadcn/ui Form components — renders accessible form fields with proper labels, error messages, and aria attributes
Zod Schema Definitions
Define schemas at module level (outside the component). Use z.infer to derive the TypeScript type.
Basic Validation
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
Conditional Validation
Use .refine() or .superRefine() for cross-field validation:
const authSchema = z
.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string().optional(),
mode: z.enum(['login', 'register']),
})
.refine(
(data) => {
if (data.mode === 'register') {
return data.confirmPassword === data.password;
}
return true;
},
{
message: 'Passwords do not match',
path: ['confirmPassword'],
},
);
type AuthFormValues = z.infer<typeof authSchema>;
Custom Error Messages
const postSchema = z.object({
title: z.string().min(1, 'Title cannot be empty').max(200, 'Title must be under 200 characters').trim(),
});
type PostFormValues = z.infer<typeof postSchema>;
useForm + zodResolver Setup
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type FormValues = z.infer<typeof formSchema>;
// Inside your component:
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
Key points:
- •
zodResolverconnects Zod validation to react-hook-form - •
defaultValuesis required for controlled inputs — always provide it - •The generic
<FormValues>ensures full type safety onform.handleSubmit,form.watch,form.setError, etc.
shadcn/ui Form Component Usage
The full rendering pattern uses Form, FormField, FormItem, FormLabel, FormControl, and FormMessage:
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
function LoginForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', password: '' },
});
const onSubmit = async (values: FormValues) => {
// handle submission
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} autoComplete="email" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" autoComplete="current-password" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Logging in...' : 'Log in'}
</Button>
</form>
</Form>
);
}
Component Hierarchy
Each form field follows this nesting structure:
Form (provider — spreads form methods into context)
└── form (HTML <form> element with handleSubmit)
└── FormField (connects a field name to react-hook-form's control)
└── FormItem (wrapper <div> that provides field context)
├── FormLabel (renders <label> with correct htmlFor)
├── FormControl (connects aria-describedby and aria-invalid)
│ └── Input / Select / Textarea (the actual input)
├── FormDescription (optional helper text)
└── FormMessage (validation error message)
Accessibility & Browser Integration
Critical Rules
- •
ALWAYS add
autoCompleteto inputs — enables browser autofill and password managers:Field Type autoComplete Value Email "email"Current password "current-password"New password "new-password"Search / other "off" - •
ALWAYS use
FormLabel— it renders a<label>element with the correcthtmlForattribute, automatically linked to the input viaFormItemcontext. - •
ALWAYS use
FormControl— it wraps the input and connectsaria-describedby(pointing toFormMessageandFormDescription) andaria-invalid(set totruewhen the field has errors). - •
FormMessageshows validation errors with properaria-describedbylinkage — screen readers announce errors automatically. - •
nameattribute is automatic — react-hook-form'sfieldspread ({...field}) includes thenameprop. Do not set it manually.
Async Form Submission
import { toast } from 'sonner';
const onSubmit = async (values: FormValues) => {
try {
const result = await someApiCall(values);
if (result.success) {
form.reset();
toast.success('Success!');
}
} catch (error) {
// Set a root-level error (not tied to a specific field)
form.setError('root', {
message: error instanceof Error ? error.message : 'Something went wrong',
});
toast.error('Something went wrong');
}
};
Server-Side Field Errors
When the server returns field-specific validation errors (e.g., "email taken"):
const onSubmit = async (values: FormValues) => {
try {
await registerUser(values);
form.reset();
toast.success('Account created!');
} catch (error) {
if (error instanceof Error && error.message.includes('email')) {
form.setError('email', { message: 'Email is already taken' });
} else {
form.setError('root', { message: 'Registration failed' });
}
}
};
Displaying Root Errors
{
form.formState.errors.root && <p className="text-sm text-destructive">{form.formState.errors.root.message}</p>;
}
Key Rules
- •ALWAYS define Zod schema OUTSIDE the component (module level) — avoids recreating the schema on every render.
- •ALWAYS use
z.infer<typeof schema>for form types — NEVER manually define form value types. - •ALWAYS provide
defaultValuesinuseForm— required for controlled inputs to avoid React warnings. - •ALWAYS use shadcn Form components for all form fields.
- •ALWAYS add appropriate
autoCompleteattributes — enables browser autofill and password managers. - •ALWAYS use
FormLabelfor accessibility — provides proper<label>withhtmlForlinkage. - •Use
form.reset()after successful submission — NOT manual state clearing. - •Use
form.setError()for server-side validation errors — supports both field-level and root-level errors. - •Use
form.formState.isSubmittingfor loading states. - •Import
toastfrom'sonner'for success/error notifications — never create custom toast state.
Quick Reference
| Task | Code |
|---|---|
| Define schema | const schema = z.object({ field: z.string().min(1) }) |
| Infer types | type T = z.infer<typeof schema> |
| Create form | useForm<T>({ resolver: zodResolver(schema), defaultValues: { ... } }) |
| Submit handler | form.handleSubmit(async (values) => { ... }) |
| Loading state | form.formState.isSubmitting |
| Reset form | form.reset() |
| Set field error | form.setError('fieldName', { message: '...' }) |
| Set root error | form.setError('root', { message: '...' }) |
| Watch field | form.watch('fieldName') |
| Check dirty | form.formState.isDirty |
| Check valid | form.formState.isValid |
| Disable on submit | <Button disabled={form.formState.isSubmitting}> |
Troubleshooting
Input not updating
Problem: Typing into an input doesn't show any text.
Solution: Make sure to spread {...field} on the Input component inside FormControl. The field spread includes value, onChange, onBlur, name, and ref.
// ❌ Wrong — missing field spread
<FormControl>
<Input />
</FormControl>
// ✅ Correct — spread field props
<FormControl>
<Input {...field} autoComplete="email" />
</FormControl>
Validation not triggering
Problem: Form submits without showing validation errors.
Solution: Ensure zodResolver is passed to useForm:
// ❌ Wrong — no resolver
const form = useForm<FormValues>({ defaultValues: { ... } });
// ✅ Correct — zodResolver connects Zod validation
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { ... },
});
Form not submitting
Problem: Clicking the submit button does nothing.
Solution: Ensure form.handleSubmit wraps your onSubmit function on the <form> element:
// ❌ Wrong — onSubmit called directly
<form onSubmit={onSubmit}>
// ✅ Correct — handleSubmit validates before calling onSubmit
<form onSubmit={form.handleSubmit(onSubmit)}>
autoComplete not working
Problem: Browser autofill doesn't trigger.
Solution: In React, the attribute is autoComplete (camelCase), not autocomplete (lowercase). Also ensure the form has appropriate autoComplete values:
// ❌ Wrong — lowercase (HTML attribute, not React)
<Input {...field} autocomplete="email" />
// ✅ Correct — camelCase (React JSX)
<Input {...field} autoComplete="email" />
FormMessage not showing errors
Problem: Validation fails but no error message appears.
Solution: Ensure <FormMessage /> is inside <FormItem> and the field name matches the Zod schema key:
// ❌ Wrong — FormMessage outside FormItem
<FormField name="email" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
</FormItem>
)} />
<FormMessage /> {/* Won't work here */}
// ✅ Correct — FormMessage inside FormItem
<FormField name="email" render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />