Form Handling and Validation 📝

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

Hands typing on a laptop keyboard, showcasing online work and technology

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. 🦎