Skip to content
intermediate Phase 8 · React Advanced

React Forms & Validation

Build forms with React Hook Form, Zod validation, controlled/uncontrolled components, and field arrays.

1h
0 problems
Topic Progress 0%

Introduction

React Forms & Validation

Forms are one of the most common UI patterns in web applications. React provides a flexible and powerful way to handle form inputs, validate user data, and manage submission logic.

Key Concepts

  • Controlled Components: Input values are driven by React state, giving you full control over the form data flow. You bind the value to state and update it via onChange handlers.
  • Uncontrolled Components: The DOM itself stores the input value, and you access it only when needed (e.g., on submit) using refs.
  • Form Libraries: Tools like React Hook Form and Formik reduce boilerplate and handle complex validation, field arrays, and error tracking.
  • Schema Validation: Libraries like Zod and Yup define validation rules declaratively and integrate seamlessly with form libraries.

Why It Matters

Poorly managed forms lead to bad UX, accessibility issues, and security vulnerabilities. Mastering React form patterns ensures clean code, performant re-renders, and reliable data collection.

Controlled vs Uncontrolled Components

Controlled vs Uncontrolled Components

Controlled Components

In a controlled component, the form data is handled entirely by React state. The input value is bound to state, and every keystroke updates that state.

import { useState } from 'react';

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Log In</button>
    </form>
  );
}

Pros: Instant validation, dynamic input formatting, controlled submission flow.
Cons: More boilerplate, one handler per input.

Uncontrolled Components

Uncontrolled components store the input value in the DOM. You read it using a ref when needed.

import { useRef } from 'react';

function SearchForm() {
  const inputRef = useRef(null);

  const handleSearch = (e) => {
    e.preventDefault();
    const query = inputRef.current.value;
    console.log('Searching for:', query);
  };

  return (
    <form onSubmit={handleSearch}>
      <input ref={inputRef} type="text" defaultValue="" placeholder="Search..." />
      <button type="submit">Search</button>
    </form>
  );
}

Pros: Less code, easier integration with non-React code.
Cons: Less control, harder to do dynamic validation or input formatting.

When to Use Which

Use controlled components when you need real-time validation, conditional disabling, or dynamic form behavior. Use uncontrolled components for simple forms with minimal state interaction or when integrating with non-React DOM manipulation.

React Hook Form

React Hook Form

React Hook Form is a performance-first form library that minimizes re-renders by using uncontrolled components internally and integrating with controlled components via the Controller wrapper.

Installation and Basic Usage

npm install react-hook-form
import { useForm } from 'react-hook-form';

function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm({
    defaultValues: {
      username: '',
      email: '',
      age: ''
    }
  });

  const onSubmit = (data) => {
    console.log('Form data:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="username">Username</label>
        <input
          id="username"
          {...register('username', {
            required: 'Username is required',
            minLength: { value: 3, message: 'Minimum 3 characters' }
          })}
        />
        {errors.username && <span className="error">{errors.username.message}</span>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Invalid email format'
            }
          })}
        />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>

      <div>
        <label htmlFor="age">Age</label>
        <input
          id="age"
          type="number"
          {...register('age', {
            required: 'Age is required',
            min: { value: 18, message: 'Must be at least 18' },
            max: { value: 120, message: 'Must be under 120' }
          })}
        />
        {errors.age && <span className="error">{errors.age.message}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Register'}
      </button>
    </form>
  );
}

Key Features

  • Minimal Re-renders: Only the affected input re-renders on change, not the entire form.
  • Validation: Built-in validation rules or integrate with Zod/Yup for schema-based validation.
  • Controller: Wrap controlled inputs (like date pickers or React Select) with Controller.
  • Watch and Get: Use watch to observe specific fields and getValues to read current values.

Using Controller for Controlled Inputs

import { useForm, Controller } from 'react-hook-form';
import Select from 'react-select';

function CountrySelect() {
  const { control, handleSubmit } = useForm();

  const countryOptions = [
    { value: 'us', label: 'United States' },
    { value: 'uk', label: 'United Kingdom' },
    { value: 'ca', label: 'Canada' }
  ];

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        name="country"
        control={control}
        rules={{ required: 'Please select a country' }}
        render={({ field }) => (
          <Select
            {...field}
            options={countryOptions}
            placeholder="Select a country"
          />
        )}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

Zod Schema Validation

Zod Schema Validation

Zod is a TypeScript-first schema declaration and validation library. It integrates seamlessly with React Hook Form to provide type-safe, declarative validation rules.

Installation

npm install zod
npm install @hookform/resolvers

Defining a Schema

import { z } from 'zod';

const userSchema = z.object({
  username: z
    .string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username must be 20 characters or fewer')
    .regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed'),
  email: z
    .string()
    .email('Please enter a valid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain at least one uppercase letter')
    .regex(/[0-9]/, 'Must contain at least one number')
    .regex(/[^a-zA-Z0-9]/, 'Must contain at least one special character'),
  confirmPassword: z.string(),
  age: z
    .number()
    .min(18, 'Must be at least 18 years old')
    .max(120, 'Invalid age'),
  bio: z
    .string()
    .max(500, 'Bio must be 500 characters or fewer')
    .optional()
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword']
});

type UserFormData = z.infer<typeof userSchema>;

Integration with React Hook Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userSchema } from './schemas';
import type { UserFormData } from './schemas';

function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isValid }
  } = useForm<UserFormData>({
    resolver: zodResolver(userSchema),
    mode: 'onChange'
  });

  const onSubmit = (data: UserFormData) => {
    console.log('Valid form data:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('username')} placeholder="Username" />
      {errors.username && <span>{errors.username.message}</span>}

      <input {...register('email')} placeholder="Email" />
      {errors.email && <span>{errors.email.message}</span>}

      <input {...register('password')} type="password" placeholder="Password" />
      {errors.password && <span>{errors.password.message}</span>}

      <input {...register('confirmPassword')} type="password" placeholder="Confirm Password" />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}

      <input {...register('age', { valueAsNumber: true })} type="number" placeholder="Age" />
      {errors.age && <span>{errors.age.message}</span>}

      <textarea {...register('bio')} placeholder="Bio (optional)" />
      {errors.bio && <span>{errors.bio.message}</span>}

      <button type="submit" disabled={!isValid}>Sign Up</button>
    </form>
  );
}

Why Zod Over Yup?

  • TypeScript-first: Zod infers types directly from schemas, no separate type definitions needed.
  • Smaller bundle size: Zod is typically smaller than Yup.
  • Better error messages: More customizable error messages with .message() or .refine().
  • Composable schemas: Build complex schemas by combining simpler ones with z.union, z.discriminatedUnion, etc.

Dynamic Field Arrays

Dynamic Field Arrays

Field arrays let you manage a dynamic list of form inputs, such as adding multiple addresses, phone numbers, or line items. React Hook Form provides the useFieldArray hook for this purpose.

Basic Field Array Example

import { useForm, useFieldArray } from 'react-hook-form';

function AddressesForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: {
      addresses: [{ street: '', city: '', zip: '' }]
    }
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'addresses'
  });

  const onSubmit = (data) => {
    console.log('All addresses:', data.addresses);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Addresses</h2>

      {fields.map((field, index) => (
        <div key={field.id} className="address-row">
          <h3>Address {index + 1}</h3>

          <input
            {...register(`addresses.${index}.street`, { required: 'Street is required' })}
            placeholder="Street"
          />

          <input
            {...register(`addresses.${index}.city`, { required: 'City is required' })}
            placeholder="City"
          />

          <input
            {...register(`addresses.${index}.zip`, {
              required: 'ZIP is required',
              pattern: { value: /^\d{5}(-\d{4})?$/, message: 'Invalid ZIP' }
            })}
            placeholder="ZIP Code"
          />

          {fields.length > 1 && (
            <button type="button" onClick={() => remove(index)}>
              Remove
            </button>
          )}
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ street: '', city: '', zip: '' })}
      >
        Add Address
      </button>

      <button type="submit">Save All</button>
    </form>
  );
}

Nested Field Arrays

You can nest arrays for complex structures like orders with multiple items, each having multiple sub-items:

const orderSchema = z.object({
  orders: z.array(z.object({
    productName: z.string().min(1),
    variants: z.array(z.object({
      size: z.string().min(1),
      quantity: z.number().min(1)
    }))
  }))
});

function OrderForm() {
  const { register, control, handleSubmit } = useForm({
    resolver: zodResolver(orderSchema),
    defaultValues: {
      orders: [{
        productName: '',
        variants: [{ size: '', quantity: 1 }]
      }]
    }
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {/* Render nested variant arrays here */}
      <button type="submit">Place Order</button>
    </form>
  );
}

Key Considerations

  • Always provide a key prop using the field's id from useFieldArray for proper React reconciliation.
  • Use control from useForm when passing to useFieldArray.
  • Performance: Only the changed field re-renders, not the entire array.

File Uploads

File Uploads

Handling file uploads in React requires careful state management and often server-side integration. Unlike text inputs, file inputs are uncontrolled by nature and require special handling.

Basic File Upload with Controlled State

import { useState, useRef } from 'react';

function AvatarUpload() {
  const [preview, setPreview] = useState(null);
  const [file, setFile] = useState(null);
  const inputRef = useRef(null);

  const handleFileChange = (e) => {
    const selected = e.target.files[0];
    if (!selected) return;

    if (!selected.type.startsWith('image/')) {
      alert('Please select an image file');
      return;
    }

    if (selected.size > 5 * 1024 * 1024) {
      alert('File must be under 5MB');
      return;
    }

    setFile(selected);

    const reader = new FileReader();
    reader.onloadend = () => {
      setPreview(reader.result);
    };
    reader.readAsDataURL(selected);
  };

  const handleUpload = async () => {
    if (!file) return;

    const formData = new FormData();
    formData.append('avatar', file);

    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData
      });
      const result = await response.json();
      console.log('Upload successful:', result.url);
    } catch (error) {
      console.error('Upload failed:', error);
    }
  };

  return (
    <div className="upload-area">
      {preview && (
        <img src={preview} alt="Preview" className="avatar-preview" />
      )}

      <input
        ref={inputRef}
        type="file"
        accept="image/png,image/jpeg,image/webp"
        onChange={handleFileChange}
        className="hidden"
      />

      <button onClick={() => inputRef.current.click()}>
        {preview ? 'Change Photo' : 'Select Photo'}
      </button>

      {file && (
        <p>
          {file.name} ({(file.size / 1024).toFixed(1)} KB)
        </p>
      )}

      <button onClick={handleUpload} disabled={!file}>
        Upload
      </button>
    </div>
  );
}

Multi-File Upload with Drag and Drop

import { useState, useCallback } from 'react';

function MultiFileUpload({ maxFiles = 10, maxSizeMB = 10 }) {
  const [files, setFiles] = useState([]);
  const [isDragging, setIsDragging] = useState(false);

  const handleDrop = useCallback((e) => {
    e.preventDefault();
    setIsDragging(false);

    const droppedFiles = Array.from(e.dataTransfer.files);
    addFiles(droppedFiles);
  }, [files]);

  const addFiles = (newFiles) => {
    const validFiles = newFiles.filter((f) => {
      if (f.size > maxSizeMB * 1024 * 1024) {
        alert(`${f.name} exceeds ${maxSizeMB}MB limit`);
        return false;
      }
      return true;
    });

    setFiles((prev) => {
      const combined = [...prev, ...validFiles];
      return combined.slice(0, maxFiles);
    });
  };

  const removeFile = (index) => {
    setFiles((prev) => prev.filter((_, i) => i !== index));
  };

  return (
    <div
      className={`dropzone ${isDragging ? 'dragging' : ''}`}
      onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
      onDragLeave={() => setIsDragging(false)}
      onDrop={handleDrop}
    >
      <p>Drag files here or click to browse</p>
      <input
        type="file"
        multiple
        onChange={(e) => addFiles(Array.from(e.target.files))}
      />

      <ul className="file-list">
        {files.map((file, i) => (
          <li key={`${file.name}-${i}`}>
            {file.name} ({(file.size / 1024).toFixed(1)} KB)
            <button onClick={() => removeFile(i)}>Remove</button>
          </li>
        ))}
      </ul>

      <p>{files.length}/{maxFiles} files selected</p>
    </div>
  );
}

Best Practices

  • Always validate file type and size on the client before upload.
  • Show upload progress with XMLHttpRequest.onprogress or fetch with streaming.
  • Generate client-side previews using URL.createObjectURL or FileReader.
  • Never trust client-side validation alone; always re-validate on the server.

Quiz

1. What is the key difference between controlled and uncontrolled components in React forms?

Question 1 options

2. Why does React Hook Form use uncontrolled components internally for better performance?

Question 2 options

3. What is the advantage of using Zod with React Hook Form over manual validation?

Question 3 options

Flashcards

Question

What is the purpose of the Controller component in React Hook Form?

Answer

Controller wraps controlled inputs (like React Select, date pickers) to integrate them with React Hook Form's register system. It bridges controlled and uncontrolled patterns, passing value, onChange, and onBlur props to the wrapped component.

Question

What does useFieldArray return and when should you use it?

Answer

useFieldArray returns fields (array of field objects with id), append, remove, move, swap, and insert methods. Use it when you need a dynamic list of inputs that can be added, removed, or reordered, such as multiple addresses or order line items.

Question

How do you handle file uploads in a controlled React component?

Answer

File inputs are inherently uncontrolled. You store the selected file in state using an onChange handler, create a preview with FileReader.readAsDataURL or URL.createObjectURL, and send it as FormData in a POST request. Always validate file type and size client-side.

Revision Notes

Key Takeaways

  • 1. Controlled components bind input value to React state; uncontrolled components use refs and the DOM stores values.
  • 2. React Hook Form minimizes re-renders by using uncontrolled components internally and provides useForm, useFieldArray, and Controller hooks.
  • 3. Zod provides TypeScript-first schema validation that integrates with React Hook Form via zodResolver for type-safe, declarative rules.
  • 4. Field arrays (useFieldArray) handle dynamic lists of inputs like addresses or order items with append, remove, and swap operations.
  • 5. File uploads require special handling: use refs, validate type/size, preview with FileReader, and send as FormData.

Interview Tips

  • Explain the trade-offs between controlled and uncontrolled components with concrete examples.
  • Describe how React Hook Form achieves performance: it avoids re-renders by not storing values in state for every keystroke.
  • Walk through implementing field arrays for a real-world scenario like a multi-item order form.
  • Discuss client-side vs server-side validation and why both are necessary.
  • Explain how Zod schemas improve type safety and reduce bugs compared to manual validation.

Cheat Sheet

React Forms Cheat Sheet:

• Controlled: value={state} + onChange={(e) => setState(e.target.value)}
• Uncontrolled: defaultValue + useRef to read on submit
• useForm: register, handleSubmit, formState (errors, isSubmitting, isValid)
• useFieldArray: fields, append, remove, swap, move
• Controller: wraps controlled inputs, passes field props
• Zod: z.object({ field: z.string().min(1) }), zodResolver(schema)
• File uploads: useRef, FileReader for preview, FormData for upload
• Validate: pattern, min, max, required in register options or Zod schema