- Consolidated multi-line arguments into single lines where appropriate in `useAuth`. - Improved spacing and readability in data processing across components (`ProfileSettingsForm`, `PasswordChangeForm`, `SessionCard`). - Applied consistent table and markdown formatting in design system docs (e.g., `README.md`, `08-ai-guidelines.md`, `00-quick-start.md`). - Updated code snippets to ensure adherence to Prettier rules and streamlined JSX structures.
19 KiB
19 KiB
Forms Guide
Master form patterns with react-hook-form + Zod validation: Learn field layouts, error handling, loading states, and accessibility best practices for bulletproof forms.
Table of Contents
- Form Architecture
- Basic Form Pattern
- Field Patterns
- Validation with Zod
- Error Handling
- Loading & Submit States
- Form Layouts
- Advanced Patterns
Form Architecture
Technology Stack
- react-hook-form - Form state management, validation
- Zod - Schema validation
- @hookform/resolvers - Zod resolver for react-hook-form
- shadcn/ui components - Input, Label, Button, etc.
Why this stack?
- ✅ Type-safe validation (TypeScript + Zod)
- ✅ Minimal re-renders (react-hook-form)
- ✅ Accessible by default (shadcn/ui)
- ✅ Easy error handling
- ✅ Built-in loading states
Form Decision Tree
Need a form?
│
├─ Single field (search, filter)?
│ └─> Use uncontrolled input with onChange
│ <Input onChange={(e) => setQuery(e.target.value)} />
│
├─ Simple form (1-3 fields, no complex validation)?
│ └─> Use react-hook-form without Zod
│ const form = useForm();
│
└─ Complex form (4+ fields, validation, async submit)?
└─> Use react-hook-form + Zod
const form = useForm({ resolver: zodResolver(schema) });
Basic Form Pattern
Minimal Form (No Validation)
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface FormData {
email: string;
}
export function SimpleForm() {
const form = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...form.register('email')} />
</div>
<Button type="submit">Submit</Button>
</form>
);
}
Complete Form Pattern (with Zod)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
// 1. Define validation schema
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
// 2. Infer TypeScript type from schema
type FormData = z.infer<typeof formSchema>;
export function LoginForm() {
// 3. Initialize form with Zod resolver
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
// 4. Submit handler (type-safe!)
const onSubmit = async (data: FormData) => {
try {
await loginUser(data);
toast.success('Logged in successfully');
} catch (error) {
toast.error('Invalid credentials');
}
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Email field */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...form.register('email')}
aria-invalid={!!form.formState.errors.email}
aria-describedby={form.formState.errors.email ? 'email-error' : undefined}
/>
{form.formState.errors.email && (
<p id="email-error" className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
{/* Password field */}
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...form.register('password')}
aria-invalid={!!form.formState.errors.password}
aria-describedby={form.formState.errors.password ? 'password-error' : undefined}
/>
{form.formState.errors.password && (
<p id="password-error" className="text-sm text-destructive">
{form.formState.errors.password.message}
</p>
)}
</div>
{/* Submit button */}
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
{form.formState.isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</form>
);
}
Key points:
- Define Zod schema first
- Infer TypeScript type with
z.infer - Use
zodResolverinuseForm - Register fields with
{...form.register('fieldName')} - Show errors from
form.formState.errors - Disable submit during submission
Field Patterns
Text Input
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
aria-describedby={form.formState.errors.name ? 'name-error' : undefined}
/>
{form.formState.errors.name && (
<p id="name-error" className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
Textarea
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" rows={4} {...form.register('description')} />
{form.formState.errors.description && (
<p className="text-sm text-destructive">{form.formState.errors.description.message}</p>
)}
</div>
Select
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)}>
<SelectTrigger id="role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="guest">Guest</SelectItem>
</SelectContent>
</Select>
{form.formState.errors.role && (
<p className="text-sm text-destructive">{form.formState.errors.role.message}</p>
)}
</div>
Checkbox
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={form.watch('acceptTerms')}
onCheckedChange={(checked) => form.setValue('acceptTerms', checked as boolean)}
/>
<Label htmlFor="terms" className="text-sm font-normal">
I accept the terms and conditions
</Label>
</div>;
{
form.formState.errors.acceptTerms && (
<p className="text-sm text-destructive">{form.formState.errors.acceptTerms.message}</p>
);
}
Radio Group (Custom Pattern)
<div className="space-y-2">
<Label>Notification Method</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input type="radio" id="email" value="email" {...form.register('notificationMethod')} />
<Label htmlFor="email" className="font-normal">
Email
</Label>
</div>
<div className="flex items-center space-x-2">
<input type="radio" id="sms" value="sms" {...form.register('notificationMethod')} />
<Label htmlFor="sms" className="font-normal">
SMS
</Label>
</div>
</div>
</div>
Validation with Zod
Common Validation Patterns
import { z } from 'zod';
// Email
z.string().email('Invalid email address');
// Min/max length
z.string().min(8, 'Minimum 8 characters').max(100, 'Maximum 100 characters');
// Required field
z.string().min(1, 'This field is required');
// Optional field
z.string().optional();
// Number with range
z.number().min(0).max(100);
// Number from string input
z.coerce.number().min(0);
// Enum
z.enum(['admin', 'user', 'guest'], {
errorMap: () => ({ message: 'Invalid role' }),
});
// URL
z.string().url('Invalid URL');
// Password with requirements
z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number');
// Confirm password
z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// Custom validation
z.string().refine((val) => !val.includes('badword'), {
message: 'Invalid input',
});
// Conditional fields
z.object({
role: z.enum(['admin', 'user']),
adminKey: z.string().optional(),
}).refine(
(data) => {
if (data.role === 'admin') {
return !!data.adminKey;
}
return true;
},
{
message: 'Admin key required for admin role',
path: ['adminKey'],
}
);
Full Form Schema Example
const userFormSchema = z.object({
// Required text
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
// Email
email: z.string().email('Invalid email address'),
// Optional phone
phone: z.string().optional(),
// Number
age: z.coerce.number().min(18, 'Must be 18 or older').max(120),
// Enum
role: z.enum(['admin', 'user', 'guest']),
// Boolean
acceptTerms: z.boolean().refine((val) => val === true, {
message: 'You must accept the terms',
}),
// Nested object
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
zip: z.string().regex(/^\d{5}$/, 'Invalid ZIP code'),
}),
// Array
tags: z.array(z.string()).min(1, 'At least one tag required'),
});
type UserFormData = z.infer<typeof userFormSchema>;
Error Handling
Field-Level Errors
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...form.register('email')}
className={form.formState.errors.email ? 'border-destructive' : ''}
aria-invalid={!!form.formState.errors.email}
aria-describedby={form.formState.errors.email ? 'email-error' : undefined}
/>
{form.formState.errors.email && (
<p id="email-error" className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
Accessibility notes:
- Use
aria-invalidto indicate error state - Use
aria-describedbyto link error message - Error ID format:
{fieldName}-error
Form-Level Errors
const onSubmit = async (data: FormData) => {
try {
await submitForm(data);
} catch (error) {
// Set form-level error
form.setError('root', {
type: 'server',
message: error.message || 'Something went wrong',
});
}
};
// Display form-level error
{
form.formState.errors.root && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{form.formState.errors.root.message}</AlertDescription>
</Alert>
);
}
Server Validation Errors
const onSubmit = async (data: FormData) => {
try {
await createUser(data);
} catch (error) {
if (error.response?.data?.errors) {
// Map server errors to form fields
const serverErrors = error.response.data.errors;
Object.keys(serverErrors).forEach((field) => {
form.setError(field as keyof FormData, {
type: 'server',
message: serverErrors[field],
});
});
} else {
// Generic error
form.setError('root', {
type: 'server',
message: 'Failed to create user',
});
}
}
};
Loading & Submit States
Basic Loading State
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
Disable All Fields During Submit
const isDisabled = form.formState.isSubmitting;
<Input
{...form.register('name')}
disabled={isDisabled}
/>
<Button type="submit" disabled={isDisabled}>
{isDisabled ? 'Submitting...' : 'Submit'}
</Button>
Loading with Toast
const onSubmit = async (data: FormData) => {
const loadingToast = toast.loading('Creating user...');
try {
await createUser(data);
toast.success('User created successfully', { id: loadingToast });
router.push('/users');
} catch (error) {
toast.error('Failed to create user', { id: loadingToast });
}
};
Form Layouts
Centered Form (Login, Signup)
<div className="container mx-auto px-4 py-8">
<Card className="max-w-md mx-auto">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Form fields */}
<Button type="submit" className="w-full">
Sign In
</Button>
</form>
</CardContent>
</Card>
</div>
Two-Column Form
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Row 1: Two columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" {...form.register('firstName')} />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" {...form.register('lastName')} />
</div>
</div>
{/* Row 2: Full width */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...form.register('email')} />
</div>
<Button type="submit">Save</Button>
</form>
Form with Sections
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Section 1 */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">Personal Information</h3>
<p className="text-sm text-muted-foreground">Basic details about you</p>
</div>
<Separator />
<div className="space-y-4">{/* Fields */}</div>
</div>
{/* Section 2 */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">Account Settings</h3>
<p className="text-sm text-muted-foreground">Configure your account preferences</p>
</div>
<Separator />
<div className="space-y-4">{/* Fields */}</div>
</div>
<div className="flex justify-end gap-4">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</form>
Advanced Patterns
Dynamic Fields (Array)
import { useFieldArray } from 'react-hook-form';
const schema = z.object({
items: z
.array(
z.object({
name: z.string().min(1),
quantity: z.coerce.number().min(1),
})
)
.min(1, 'At least one item required'),
});
function DynamicForm() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
items: [{ name: '', quantity: 1 }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
});
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4">
<Input {...form.register(`items.${index}.name`)} placeholder="Item name" />
<Input
type="number"
{...form.register(`items.${index}.quantity`)}
placeholder="Quantity"
/>
<Button type="button" variant="destructive" onClick={() => remove(index)}>
Remove
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={() => append({ name: '', quantity: 1 })}>
Add Item
</Button>
<Button type="submit">Submit</Button>
</form>
);
}
Conditional Fields
const schema = z
.object({
role: z.enum(['user', 'admin']),
adminKey: z.string().optional(),
})
.refine(
(data) => {
if (data.role === 'admin') {
return !!data.adminKey;
}
return true;
},
{
message: 'Admin key required',
path: ['adminKey'],
}
);
function ConditionalForm() {
const form = useForm({ resolver: zodResolver(schema) });
const role = form.watch('role');
return (
<form className="space-y-4">
<Select value={role} onValueChange={(val) => form.setValue('role', val as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
{role === 'admin' && <Input {...form.register('adminKey')} placeholder="Admin Key" />}
</form>
);
}
File Upload
const schema = z.object({
file: z.instanceof(FileList).refine((files) => files.length > 0, {
message: 'File is required',
}),
});
<input type="file" {...form.register('file')} accept="image/*" />;
const onSubmit = (data: FormData) => {
const file = data.file[0]; // FileList -> File
const formData = new FormData();
formData.append('file', file);
// Upload formData
};
Form Checklist
Before shipping a form, verify:
Functionality
- All fields register correctly
- Validation works (test invalid inputs)
- Submit handler fires
- Loading state works
- Error messages display
- Success case redirects/shows success
Accessibility
- Labels associated with inputs (
htmlFor+id) - Error messages use
aria-describedby - Invalid inputs have
aria-invalid - Focus order is logical (Tab through form)
- Submit button disabled during submission
UX
- Field errors appear on blur or submit
- Loading state prevents double-submit
- Success message or redirect on success
- Cancel button clears form or navigates away
- Mobile-friendly (responsive layout)
Next Steps
- Interactive Examples: Form examples
- Components: Form components
- Accessibility: Form accessibility
Related Documentation:
- Components - Input, Label, Button, Select
- Layouts - Form layout patterns
- Accessibility - ARIA attributes for forms
External Resources:
Last Updated: November 2, 2025