Form Handling and Validation 📝
Master form management in CrittrHavens with React Hook Form and Zod. A comprehensive guide to building robust, validated forms.

React Hook Form Basics
Performant forms with minimal re-renders.
Why React Hook Form?
Benefits:
- Minimal re-renders for better performance
- Built-in validation support
- Easy integration with UI libraries
- Excellent TypeScript support
- Small bundle size (~25kb)
- Uncontrolled components by default
Basic Form Setup
Simple Form Example:
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface FormData {
name: string;
species: string;
morph?: string;
}
function CrittrForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>();
const onSubmit = async (data: FormData) => {
console.log('Form data:', data);
// Save to database
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input
{...register('name', { required: 'Name is required' })}
placeholder="Crittr name"
/>
{errors.name && (
<span className="text-red-500">{errors.name.message}</span>
)}
<Input
{...register('species', { required: 'Species is required' })}
placeholder="Species"
/>
<Input
{...register('morph')}
placeholder="Morph (optional)"
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Crittr'}
</Button>
</form>
);
}
Form Methods
useForm Hook:
const form = useForm({
// Default values
defaultValues: {
name: '',
species: 'Ball Python',
},
// Validation mode
mode: 'onChange', // 'onSubmit' | 'onBlur' | 'onChange' | 'all'
// Revalidation mode
reValidateMode: 'onChange',
// Resolver for schema validation
resolver: zodResolver(schema),
});
// Available methods
const {
register, // Register input
handleSubmit, // Form submission
watch, // Watch field values
setValue, // Set field value
getValues, // Get all values
reset, // Reset form
trigger, // Trigger validation
formState, // Form state
} = form;
Zod Schema Validation
Type-safe schema validation.
Defining Schemas
Zod Schema Example:
import { z } from 'zod';
// Define schema
const crittrSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.max(50, 'Name must be less than 50 characters'),
species: z.string()
.min(1, 'Species is required'),
morph: z.string().optional(),
birthDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format'),
weight: z.number()
.positive('Weight must be positive')
.max(10000, 'Weight seems too high'),
tags: z.array(z.string())
.min(1, 'At least one tag required')
.max(5, 'Maximum 5 tags allowed'),
});
// Infer TypeScript type
type CrittrFormData = z.infer<typeof crittrSchema>;
Integration with React Hook Form
Using Zod Resolver:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function ValidatedForm() {
const form = useForm<CrittrFormData>({
resolver: zodResolver(crittrSchema),
defaultValues: {
name: '',
species: '',
tags: [],
},
});
const onSubmit = async (data: CrittrFormData) => {
// Data is validated and typed
await saveCrittr(data);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* Form fields */}
</form>
);
}
Advanced Validations
Complex Validation Rules:
const advancedSchema = z.object({
// Email validation
email: z.string().email('Invalid email address'),
// Password with requirements
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[0-9]/, 'Must contain number'),
// Confirm password
confirmPassword: z.string(),
// Conditional validation
hasPets: z.boolean(),
petCount: z.number().optional(),
// Custom validation
feedingSchedule: z.string().refine(
(val) => {
const hours = parseInt(val.split(':')[0]);
return hours >= 6 && hours <= 22;
},
{ message: 'Feeding time must be between 6 AM and 10 PM' }
),
}).refine(
// Cross-field validation
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ['confirmPassword'],
}
).refine(
// Conditional requirement
(data) => !data.hasPets || (data.petCount && data.petCount > 0),
{
message: 'Pet count required when has pets',
path: ['petCount'],
}
);
Form Component Patterns
Building reusable form components.
Form Field Component
Reusable Field Wrapper:
import { UseFormReturn } from 'react-hook-form';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
interface FormInputProps {
form: UseFormReturn<any>;
name: string;
label: string;
placeholder?: string;
type?: string;
}
export function FormInput({
form,
name,
label,
placeholder,
type = 'text',
}: FormInputProps) {
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
{...field}
type={type}
placeholder={placeholder}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
Select Field Component
Dropdown with Validation:
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export function FormSelect({
form,
name,
label,
options,
placeholder = 'Select...',
}) {
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FormControl>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
);
}
Checkbox Group
Multiple Selection:
export function FormCheckboxGroup({ form, name, label, options }) {
return (
<FormField
control={form.control}
name={name}
render={() => (
<FormItem>
<FormLabel>{label}</FormLabel>
{options.map((option) => (
<FormField
key={option.value}
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Checkbox
checked={field.value?.includes(option.value)}
onCheckedChange={(checked) => {
const current = field.value || [];
const updated = checked
? [...current, option.value]
: current.filter((v) => v !== option.value);
field.onChange(updated);
}}
/>
</FormControl>
<FormLabel className="font-normal">
{option.label}
</FormLabel>
</FormItem>
)}
/>
))}
<FormMessage />
</FormItem>
)}
/>
);
}
Field Validation Rules
Common validation patterns.
Built-in Validations
HTML5 Validations:
// React Hook Form built-in rules
{
required: 'This field is required',
minLength: {
value: 3,
message: 'Minimum 3 characters',
},
maxLength: {
value: 50,
message: 'Maximum 50 characters',
},
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
min: {
value: 0,
message: 'Must be positive',
},
max: {
value: 100,
message: 'Maximum 100',
},
validate: (value) => value !== 'admin' || 'Username not available',
}
Custom Validators
Async Validation:
const checkUsername = async (username: string) => {
const { data } = await supabase
.from('profiles')
.select('id')
.eq('username', username)
.single();
return !data || 'Username already taken';
};
// Use in form
<Input
{...register('username', {
validate: checkUsername,
})}
/>
Conditional Validation
Dynamic Rules:
const watchHabitType = watch('habitatType');
<FormField
name="humidity"
rules={{
required: watchHabitType === 'tropical'
? 'Humidity required for tropical habitats'
: false,
min: watchHabitType === 'tropical'
? { value: 60, message: 'Tropical habitats need 60%+ humidity' }
: undefined,
}}
/>
Error Message Display
User-friendly error presentation.
Error Components
Error Message Display:
function FormError({ error }: { error?: FieldError }) {
if (!error) return null;
return (
<div className="flex items-center gap-2 text-red-500 text-sm mt-1">
<AlertCircle className="h-4 w-4" />
<span>{error.message}</span>
);
}
Field-Level Errors
Inline Validation:
<div className="space-y-2">
<Input
{...register('email')}
className={errors.email ? 'border-red-500' : ''}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<p id="email-error" className="text-red-500 text-sm">
{errors.email.message}
</p>
)}
Summary Errors
Form-Level Errors:
function ErrorSummary({ errors }: { errors: FieldErrors }) {
const errorMessages = Object.entries(errors);
if (errorMessages.length === 0) return null;
return (
<Alert variant="destructive">
<AlertTitle>Please fix the following errors:</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-4">
{errorMessages.map(([field, error]) => (
<li key={field}>
<strong>{field}:</strong> {error?.message}
</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
Form Submission Handling
Processing form data.
Basic Submission
Handle Submit:
const onSubmit = async (data: FormData) => {
try {
setIsLoading(true);
const { error } = await supabase
.from('crittrs')
.insert(data);
if (error) throw error;
toast.success('Crittr added successfully!');
form.reset();
router.push('/crittrs');
} catch (error) {
toast.error('Failed to save crittr');
console.error(error);
} finally {
setIsLoading(false);
}
};
Error Handling
Submission Errors:
const onSubmit = async (data: FormData) => {
try {
await saveCrittr(data);
} catch (error) {
if (error.code === 'duplicate_name') {
form.setError('name', {
type: 'manual',
message: 'A crittr with this name already exists',
});
} else {
form.setError('root', {
type: 'manual',
message: 'Something went wrong. Please try again.',
});
}
}
};
Success Feedback
Post-Submission:
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const onSubmit = async (data: FormData) => {
try {
await saveCrittr(data);
setSubmitStatus('success');
// Show success message
setTimeout(() => {
setSubmitStatus('idle');
onClose();
}, 2000);
} catch {
setSubmitStatus('error');
}
};
// In render
{submitStatus === 'success' && (
<Alert className="bg-green-50">
<CheckCircle className="h-4 w-4" />
<AlertDescription>Saved successfully!</AlertDescription>
</Alert>
)}
Optimistic Updates
Instant UI feedback.
Optimistic Form Updates
Update UI Before Server:
const { mutate: createCrittr } = useMutation({
mutationFn: saveCrittr,
onMutate: async (newCrittr) => {
// Cancel queries
await queryClient.cancelQueries(['crittrs']);
// Snapshot previous value
const previousCrittrs = queryClient.getQueryData(['crittrs']);
// Optimistically update
queryClient.setQueryData(['crittrs'], (old) => [
...old,
{ ...newCrittr, id: 'temp-' + Date.now() },
]);
// Return context for rollback
return { previousCrittrs };
},
onError: (err, newCrittr, context) => {
// Rollback on error
queryClient.setQueryData(['crittrs'], context.previousCrittrs);
toast.error('Failed to save');
},
onSettled: () => {
// Always refetch
queryClient.invalidateQueries(['crittrs']);
},
});
const onSubmit = (data: FormData) => {
createCrittr(data);
form.reset();
onClose();
};
File Upload Handling
Managing file inputs.
File Upload Component
Image Upload:
function ImageUpload({ form, name }) {
const [preview, setPreview] = useState<string>();
return (
<FormField
control={form.control}
name={name}
render={({ field: { onChange, value, ...field } }) => (
<FormItem>
<FormLabel>Photo</FormLabel>
<FormControl>
<Input
{...field}
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file);
// Preview
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
}}
/>
</FormControl>
{preview && (
<img
src={preview}
alt="Preview"
className="w-32 h-32 object-cover rounded"
/>
)}
<FormMessage />
</FormItem>
)}
/>
);
}
File Validation
Validate Files:
const fileSchema = z.object({
photo: z.instanceof(File)
.refine((file) => file.size <= 5000000, 'Max file size is 5MB')
.refine(
(file) => ['image/jpeg', 'image/png'].includes(file.type),
'Only JPEG and PNG files are accepted'
),
});
Upload to Storage
Supabase Storage:
const uploadPhoto = async (file: File, crittrId: string) => {
const fileName = `${crittrId}/${Date.now()}-${file.name}`;
const { data, error } = await supabase.storage
.from('crittr-photos')
.upload(fileName, file);
if (error) throw error;
const { data: { publicUrl } } = supabase.storage
.from('crittr-photos')
.getPublicUrl(fileName);
return publicUrl;
};
Dynamic Form Fields
Forms that adapt to user input.
Field Arrays
Dynamic List of Fields:
import { useFieldArray } from 'react-hook-form';
function MeasurementForm() {
const form = useForm({
defaultValues: {
measurements: [{ type: 'weight', value: 0, unit: 'g' }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'measurements',
});
return (
<form>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<FormInput
form={form}
name={`measurements.${index}.type`}
label="Type"
/>
<FormInput
form={form}
name={`measurements.${index}.value`}
label="Value"
type="number"
/>
<Button
type="button"
variant="ghost"
onClick={() => remove(index)}
>
Remove
</Button>
))}
<Button
type="button"
onClick={() => append({ type: '', value: 0, unit: 'g' })}
>
Add Measurement
</Button>
</form>
);
}
Conditional Fields
Show/Hide Based on Selection:
function HabitatForm() {
const form = useForm();
const habitatType = form.watch('type');
return (
<form>
<FormSelect
form={form}
name="type"
label="Habitat Type"
options={[
{ value: 'terrestrial', label: 'Terrestrial' },
{ value: 'arboreal', label: 'Arboreal' },
{ value: 'aquatic', label: 'Aquatic' },
]}
/>
{habitatType === 'aquatic' && (
<>
<FormInput
form={form}
name="waterDepth"
label="Water Depth (cm)"
type="number"
/>
<FormInput
form={form}
name="waterTemp"
label="Water Temperature (°C)"
type="number"
/>
</>
)}
{habitatType === 'arboreal' && (
<FormInput
form={form}
name="branchHeight"
label="Branch Height (cm)"
type="number"
/>
)}
</form>
);
}
Form Testing Strategies
Testing form behavior.
Unit Tests
Testing Forms:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('CrittrForm', () => {
it('validates required fields', async () => {
render(<CrittrForm />);
const submitButton = screen.getByRole('button', { name: /save/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument();
expect(screen.getByText('Species is required')).toBeInTheDocument();
});
});
it('submits valid form data', async () => {
const onSubmit = vi.fn();
render(<CrittrForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText('Name'), 'Sunny');
await userEvent.type(screen.getByLabelText('Species'), 'Ball Python');
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Sunny',
species: 'Ball Python',
});
});
});
});
Validation Testing
Test Schema Validation:
describe('crittrSchema', () => {
it('validates correct data', () => {
const validData = {
name: 'Sunny',
species: 'Ball Python',
weight: 500,
};
const result = crittrSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('rejects invalid data', () => {
const invalidData = {
name: '',
species: 'Ball Python',
weight: -100,
};
const result = crittrSchema.safeParse(invalidData);
expect(result.success).toBe(false);
expect(result.error.issues).toHaveLength(2);
});
});
Best Practices
Form development guidelines.
Performance Tips
Optimization Strategies:
// 1. Use uncontrolled components
<Input {...register('name')} /> // Good
// vs
<Input value={value} onChange={onChange} /> // More re-renders
// 2. Debounce validation
const debouncedValidate = useMemo(
() => debounce(async (value) => {
return await checkUsername(value);
}, 500),
[]
);
// 3. Lazy load heavy forms
const HeavyForm = lazy(() => import('./HeavyForm'));
Accessibility
Form Accessibility:
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div role="group" aria-labelledby="basic-info">
<h3 id="basic-info">Basic Information</h3>
<label htmlFor="name">
Name <span aria-label="required">*</span>
</label>
<Input
id="name"
{...register('name')}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert">
{errors.name.message}
</span>
)}
</form>
Common Patterns
Reusable form solutions.
Multi-Step Form
function MultiStepForm() {
const [step, setStep] = useState(1);
const form = useForm();
const nextStep = async () => {
const isValid = await form.trigger();
if (isValid) setStep(step + 1);
};
return (
<form>
{step === 1 && <BasicInfoStep form={form} />}
{step === 2 && <DetailsStep form={form} />}
{step === 3 && <ReviewStep form={form} />}
<div className="flex justify-between">
{step > 1 && (
<Button onClick={() => setStep(step - 1)}>Back</Button>
)}
{step < 3 ? (
<Button onClick={nextStep}>Next</Button>
) : (
<Button type="submit">Submit</Button>
)}
</form>
);
}
Search Form
function SearchForm() {
const [search] = useDebounce(watch('search'), 500);
useEffect(() => {
if (search) {
performSearch(search);
}
}, [search]);
return (
<Input
{...register('search')}
placeholder="Search crittrs..."
/>
);
}
Next Steps
Continue mastering forms.
Building forms that are a joy to fill out, even for tracking feeding schedules. 🦎