Skip to main content

πŸ“ Lesson 7.1: Complex Form Handling

Forms are the backbone of user interaction in web applications. While simple forms are straightforward, real-world applications often require handling complex scenarios like multiple steps, dynamic fields, conditional validation, and nested data structures. In this lesson, you'll master the patterns and techniques needed to build robust, user-friendly forms in React with TypeScript.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Manage complex form state with multiple fields and nested data structures
  • Implement comprehensive validation strategies with real-time feedback
  • Build dynamic forms with field arrays and conditional fields
  • Create clear, accessible error messaging for users
  • Optimize form performance to prevent unnecessary re-renders
  • Handle advanced form scenarios like multi-step wizards and dependent fields
  • Type form data and handlers properly with TypeScript

Estimated Time: 60-75 minutes

Project: Build a comprehensive job application form with validation, dynamic fields, and multi-step flow

πŸ“‘ In This Lesson

πŸ“‹ Introduction to Complex Forms

Forms are everywhere in web applicationsβ€”user registration, profile editing, checkout flows, search filters, content creation, and much more. While simple forms with a few fields are straightforward, real-world applications often need to handle much more complex scenarios.

What Makes a Form "Complex"?

A form becomes complex when it involves one or more of these challenges:

  • Many fields - 10+ inputs that need to be managed and validated
  • Nested data - Form data with multiple levels of nesting (address.city, user.profile.bio)
  • Dynamic fields - Fields that can be added/removed (multiple phone numbers, education history)
  • Conditional logic - Fields that appear based on other field values
  • Multi-step flows - Forms spread across multiple pages or steps
  • Complex validation - Rules that depend on multiple fields or external data
  • Async validation - Checking username availability, validating addresses
  • File uploads - Handling images, documents, or multiple files
  • Performance concerns - Preventing re-renders on every keystroke

Common Complex Form Scenarios

graph TB A[Complex Forms] --> B[Job Applications] A --> C[E-commerce Checkout] A --> D[User Onboarding] A --> E[Admin Panels] A --> F[Survey Forms] B --> B1[Personal Info] B --> B2[Work History Array] B --> B3[Education Array] B --> B4[References] C --> C1[Shipping Address] C --> C2[Billing Address] C --> C3[Payment Info] C --> C4[Order Review] D --> D1[Account Setup] D --> D2[Profile Building] D --> D3[Preferences] style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style C fill:#48bb78,color:#fff style D fill:#48bb78,color:#fff

πŸ’‘ Real-World Example: Job Application Form

Consider a job application form that includes:

  • Personal information (name, email, phone, address)
  • Multiple work experiences (each with company, position, dates, description)
  • Multiple education entries (school, degree, year)
  • Skills and certifications
  • Reference contacts
  • Resume upload
  • Cover letter (optional based on position)

This form needs to handle dynamic arrays, validation across sections, conditional fields, file uploads, and a multi-step flow. That's complexity!

The Challenges We'll Address

Throughout this lesson, we'll tackle these key challenges:

⚠️ Form Complexity Challenges

  1. State Management - How do we efficiently manage state for dozens of fields?
  2. Validation - When and how do we validate? Real-time, on blur, on submit?
  3. Error Display - How do we show errors clearly without overwhelming users?
  4. TypeScript - How do we type complex, nested form data?
  5. Performance - How do we prevent the entire form from re-rendering on every keystroke?
  6. User Experience - How do we keep complex forms feeling simple and intuitive?
  7. Accessibility - How do we ensure screen readers announce errors properly?

Our Approach

In this lesson, we'll start with vanilla React form handling to understand the fundamentals. This foundation will help you appreciate why form libraries exist and how they solve these problems. In the next lessons, we'll introduce React Hook Form and Zod, which make complex forms much easier to manage.

βœ… Why Learn the Hard Way First?

Understanding how to build forms with vanilla React teaches you:

  • The underlying principles that all form libraries use
  • How to debug form issues when they arise
  • When you actually need a form library vs. when vanilla React is sufficient
  • How to build custom solutions for unique requirements
  • What problems form libraries solve (so you appreciate them more!)

πŸ—‚οΈ Form State Management Patterns

Managing form state is the foundation of form handling. Let's explore different patterns, from simple to sophisticated, and understand when to use each approach.

Pattern 1: Individual useState for Each Field

The simplest approach is to create separate state variables for each form field:

// Simple but doesn't scale well
function SimpleForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [phone, setPhone] = useState('');
  const [address, setAddress] = useState('');
  // ... imagine 20 more fields

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log({ name, email, phone, address });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
      />
      <input 
        value={email} 
        onChange={(e) => setEmail(e.target.value)} 
      />
      {/* More inputs... */}
    </form>
  );
}

❌ Problems with This Approach

  • Doesn't scale - Imagine 30 fields with 30 useState calls!
  • Verbose - Lots of repetitive code
  • Hard to validate - Where do validation errors go?
  • Difficult to submit - Must manually gather all values
  • No structure - Can't represent nested data easily

βœ… When to Use This Pattern

This approach is fine for:

  • Very simple forms with 2-3 fields
  • Forms where each field has very different handling logic
  • Temporary prototypes

Pattern 2: Single Object State

A better approach is to use a single state object that holds all form data:

// Better: Single object for all form data
interface FormData {
  name: string;
  email: string;
  phone: string;
  address: {
    street: string;
    city: string;
    state: string;
    zip: string;
  };
}

function BetterForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    phone: '',
    address: {
      street: '',
      city: '',
      state: '',
      zip: ''
    }
  });

  // Generic handler for simple fields
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  // Handler for nested fields
  const handleAddressChange = (field: keyof FormData['address']) => 
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setFormData(prev => ({
        ...prev,
        address: {
          ...prev.address,
          [field]: e.target.value
        }
      }));
    };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log(formData); // All data in one object
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        name="name"
        value={formData.name} 
        onChange={handleChange} 
      />
      <input 
        name="email"
        value={formData.email} 
        onChange={handleChange} 
      />
      
      {/* Nested fields */}
      <input 
        value={formData.address.street} 
        onChange={handleAddressChange('street')} 
      />
      <input 
        value={formData.address.city} 
        onChange={handleAddressChange('city')} 
      />
    </form>
  );
}

πŸ’‘ Advantages of Object State

  • Scalable - Easily add more fields
  • Type-safe - TypeScript knows your data structure
  • Structured - Supports nested data naturally
  • Easy submission - Data is already in the right format
  • Reusable handlers - Generic functions work for many fields

Pattern 3: useReducer for Complex State

For very complex forms, useReducer can provide better structure and maintainability:

// Advanced: useReducer for complex form logic
interface FormState {
  formData: {
    name: string;
    email: string;
    phone: string;
  };
  errors: {
    name?: string;
    email?: string;
    phone?: string;
  };
  isSubmitting: boolean;
  isSubmitted: boolean;
}

type FormAction =
  | { type: 'SET_FIELD'; field: string; value: string }
  | { type: 'SET_ERROR'; field: string; error: string }
  | { type: 'CLEAR_ERROR'; field: string }
  | { type: 'START_SUBMIT' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_FAILURE'; errors: Record<string, string> }
  | { type: 'RESET' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        formData: {
          ...state.formData,
          [action.field]: action.value
        },
        // Clear error when user starts typing
        errors: {
          ...state.errors,
          [action.field]: undefined
        }
      };
    
    case 'SET_ERROR':
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.field]: action.error
        }
      };
    
    case 'CLEAR_ERROR':
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.field]: undefined
        }
      };
    
    case 'START_SUBMIT':
      return {
        ...state,
        isSubmitting: true
      };
    
    case 'SUBMIT_SUCCESS':
      return {
        ...state,
        isSubmitting: false,
        isSubmitted: true,
        errors: {}
      };
    
    case 'SUBMIT_FAILURE':
      return {
        ...state,
        isSubmitting: false,
        errors: action.errors
      };
    
    case 'RESET':
      return initialFormState;
    
    default:
      return state;
  }
}

const initialFormState: FormState = {
  formData: { name: '', email: '', phone: '' },
  errors: {},
  isSubmitting: false,
  isSubmitted: false
};

function AdvancedForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  const handleChange = (field: string) => 
    (e: React.ChangeEvent<HTMLInputElement>) => {
      dispatch({ 
        type: 'SET_FIELD', 
        field, 
        value: e.target.value 
      });
    };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    // Validate
    const validationErrors = validateForm(state.formData);
    if (Object.keys(validationErrors).length > 0) {
      dispatch({ type: 'SUBMIT_FAILURE', errors: validationErrors });
      return;
    }

    dispatch({ type: 'START_SUBMIT' });
    
    try {
      await submitToAPI(state.formData);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({ 
        type: 'SUBMIT_FAILURE', 
        errors: { submit: 'Failed to submit form' } 
      });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={state.formData.name}
        onChange={handleChange('name')}
        disabled={state.isSubmitting}
      />
      {state.errors.name && <span>{state.errors.name}</span>}
      {/* More fields... */}
      
      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

πŸ’‘ When to Use useReducer

  • Complex state logic - Multiple related state updates
  • State transitions - Clear state machine patterns
  • Error handling - Need to track errors alongside data
  • Submission states - Managing loading, success, failure states
  • Testability - Reducer functions are easy to unit test
  • Team collaboration - Explicit actions document possible state changes

βœ… Pattern Comparison Summary

Pattern Best For Pros Cons
Individual useState 2-3 fields Simple, straightforward Doesn't scale, verbose
Object State Most forms (5-20 fields) Scalable, type-safe, structured Nested updates can be tricky
useReducer Complex forms (20+ fields) Predictable, testable, clear actions More boilerplate, learning curve
πŸ‹οΈ Exercise 1: Refactor to Object State

Task: Refactor this form to use a single object state instead of individual useState calls.

// Starting code (refactor this!)
function UserForm() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState('');
  const [country, setCountry] = useState('');

  return (
    <form>
      <input value={firstName} onChange={e => setFirstName(e.target.value)} />
      <input value={lastName} onChange={e => setLastName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <input value={age} onChange={e => setAge(e.target.value)} />
      <select value={country} onChange={e => setCountry(e.target.value)}>
        <option value="">Select country</option>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
      </select>
    </form>
  );
}
πŸ’‘ Hint

Create an interface for the form data, then use a single useState with that interface. Create a generic handleChange function that uses the input's name attribute.

βœ… Solution
interface UserFormData {
  firstName: string;
  lastName: string;
  email: string;
  age: string;
  country: string;
}

function UserForm() {
  const [formData, setFormData] = useState<UserFormData>({
    firstName: '',
    lastName: '',
    email: '',
    age: '',
    country: ''
  });

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
  ) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };

  return (
    <form>
      <input 
        name="firstName"
        value={formData.firstName} 
        onChange={handleChange} 
      />
      <input 
        name="lastName"
        value={formData.lastName} 
        onChange={handleChange} 
      />
      <input 
        name="email"
        type="email"
        value={formData.email} 
        onChange={handleChange} 
      />
      <input 
        name="age"
        type="number"
        value={formData.age} 
        onChange={handleChange} 
      />
      <select 
        name="country"
        value={formData.country} 
        onChange={handleChange}
      >
        <option value="">Select country</option>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
      </select>
    </form>
  );
}

βœ… Validation Strategies

Validation is crucial for ensuring data quality and providing helpful feedback to users. Let's explore different validation strategies and when to apply each one.

Types of Validation

graph LR A[Form Validation] --> B[Client-Side] A --> C[Server-Side] B --> B1[Real-time] B --> B2[On Blur] B --> B3[On Submit] B1 --> B1a[Keystroke validation] B1 --> B1b[Format checking] B2 --> B2a[Field complete] B2 --> B2b[User moved away] B3 --> B3a[All fields at once] B3 --> B3b[Before API call] C --> C1[Database constraints] C --> C2[Business rules] C --> C3[Unique checks] style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style C fill:#ed8936,color:#fff

When to Validate?

Different validation timing strategies serve different purposes:

Strategy When It Runs Best For User Experience
Real-time (onChange) Every keystroke Format validation (email, phone), password strength Immediate feedback, but can be annoying
On Blur When field loses focus Most validations (required, length, format) Good balance - validates after user finishes
On Submit When form is submitted Cross-field validation, server-side checks Least intrusive, but user finds errors late
Debounced After user stops typing (300-500ms) Async checks (username availability) Best for expensive validations

⚑ Interactive: Validation Timing Comparison

Experience how different validation strategies feel from a user's perspective

Type an Email to See Validation Timing in Action onChange onBlur onSubmit Debounced Validates on every keystroke - immediate but can feel intrusive Registration Form Email Address βœ— ⚠️ Please enter a valid email address Submit Events: Start typing to see validation events... Keystrokes: 0 | Validations run: 0

Try different strategies to see how validation timing affects user experience

Validation Pattern: Multi-Strategy Approach

The best user experience often combines multiple validation strategies:

interface FormData {
  email: string;
  password: string;
  confirmPassword: string;
  username: string;
}

interface FormErrors {
  email?: string;
  password?: string;
  confirmPassword?: string;
  username?: string;
}

function RegistrationForm() {
  const [formData, setFormData] = useState<FormData>({
    email: '',
    password: '',
    confirmPassword: '',
    username: ''
  });

  const [errors, setErrors] = useState<FormErrors>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});

  // Validation functions
  const validateEmail = (email: string): string | undefined => {
    if (!email) return 'Email is required';
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return 'Invalid email format';
    }
    return undefined;
  };

  const validatePassword = (password: string): string | undefined => {
    if (!password) return 'Password is required';
    if (password.length < 8) return 'Password must be at least 8 characters';
    if (!/[A-Z]/.test(password)) return 'Password must contain uppercase letter';
    if (!/[a-z]/.test(password)) return 'Password must contain lowercase letter';
    if (!/[0-9]/.test(password)) return 'Password must contain a number';
    return undefined;
  };

  const validateConfirmPassword = (
    password: string, 
    confirmPassword: string
  ): string | undefined => {
    if (!confirmPassword) return 'Please confirm your password';
    if (password !== confirmPassword) return 'Passwords do not match';
    return undefined;
  };

  // Real-time validation for password strength
  const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newPassword = e.target.value;
    setFormData(prev => ({ ...prev, password: newPassword }));
    
    // Real-time validation shows password strength immediately
    if (touched.password) {
      const error = validatePassword(newPassword);
      setErrors(prev => ({ ...prev, password: error }));
    }
  };

  // On blur validation for most fields
  const handleBlur = (field: keyof FormData) => () => {
    setTouched(prev => ({ ...prev, [field]: true }));
    
    let error: string | undefined;
    
    switch (field) {
      case 'email':
        error = validateEmail(formData.email);
        break;
      case 'password':
        error = validatePassword(formData.password);
        break;
      case 'confirmPassword':
        error = validateConfirmPassword(formData.password, formData.confirmPassword);
        break;
    }
    
    setErrors(prev => ({ ...prev, [field]: error }));
  };

  // On submit validation (validates everything)
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    // Mark all fields as touched
    setTouched({
      email: true,
      password: true,
      confirmPassword: true,
      username: true
    });

    // Run all validations
    const newErrors: FormErrors = {
      email: validateEmail(formData.email),
      password: validatePassword(formData.password),
      confirmPassword: validateConfirmPassword(
        formData.password, 
        formData.confirmPassword
      )
    };

    // Remove undefined errors
    Object.keys(newErrors).forEach(key => {
      if (newErrors[key as keyof FormErrors] === undefined) {
        delete newErrors[key as keyof FormErrors];
      }
    });

    setErrors(newErrors);

    // If there are errors, don't submit
    if (Object.keys(newErrors).length > 0) {
      return;
    }

    // Submit form
    try {
      await submitRegistration(formData);
      alert('Registration successful!');
    } catch (error) {
      setErrors({ email: 'Registration failed. Please try again.' });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          name="email"
          value={formData.email}
          onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
          onBlur={handleBlur('email')}
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && touched.email && (
          <span id="email-error" role="alert" style={{ color: 'red' }}>
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          name="password"
          value={formData.password}
          onChange={handlePasswordChange}
          onBlur={handleBlur('password')}
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? 'password-error' : undefined}
        />
        {errors.password && (
          <span id="password-error" role="alert" style={{ color: 'red' }}>
            {errors.password}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input
          id="confirmPassword"
          type="password"
          name="confirmPassword"
          value={formData.confirmPassword}
          onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
          onBlur={handleBlur('confirmPassword')}
          aria-invalid={!!errors.confirmPassword}
          aria-describedby={errors.confirmPassword ? 'confirm-password-error' : undefined}
        />
        {errors.confirmPassword && touched.confirmPassword && (
          <span id="confirm-password-error" role="alert" style={{ color: 'red' }}>
            {errors.confirmPassword}
          </span>
        )}
      </div>

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

πŸ’‘ Key Validation Concepts

  • Touched tracking - Only show errors after user has interacted with a field
  • Multiple strategies - Real-time for password strength, on blur for most fields
  • Submit validation - Final check before sending data
  • Accessibility - Use aria-invalid and aria-describedby for screen readers
  • Clear errors - Remove error when user starts fixing it

Async Validation

Some validations require checking with the server (e.g., username availability). These need special handling:

function UsernameField() {
  const [username, setUsername] = useState('');
  const [error, setError] = useState<string>();
  const [isChecking, setIsChecking] = useState(false);

  // Debounced validation check
  useEffect(() => {
    if (!username) {
      setError(undefined);
      return;
    }

    // Don't check immediately - wait for user to stop typing
    const timeoutId = setTimeout(async () => {
      setIsChecking(true);
      
      try {
        const available = await checkUsernameAvailability(username);
        if (!available) {
          setError('Username is already taken');
        } else {
          setError(undefined);
        }
      } catch (err) {
        setError('Could not check username availability');
      } finally {
        setIsChecking(false);
      }
    }, 500); // Wait 500ms after user stops typing

    // Cleanup function cancels the previous timeout
    return () => clearTimeout(timeoutId);
  }, [username]);

  return (
    <div>
      <label htmlFor="username">Username</label>
      <input
        id="username"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
        aria-invalid={!!error}
        aria-describedby={error ? 'username-error' : undefined}
      />
      {isChecking && <span>Checking availability...</span>}
      {error && !isChecking && (
        <span id="username-error" role="alert" style={{ color: 'red' }}>
          {error}
        </span>
      )}
    </div>
  );
}

βœ… Async Validation Best Practices

  • Debounce - Wait for user to stop typing (300-500ms)
  • Show loading state - Let user know check is in progress
  • Handle errors - Network failures shouldn't block form
  • Cancel previous requests - Use cleanup functions to avoid race conditions
  • Cache results - Don't recheck the same value twice
πŸ‹οΈ Exercise 2: Add Validation

Task: Add validation to this form with the following rules:

  • Name: Required, min 2 characters
  • Email: Required, valid email format
  • Age: Required, must be 18 or older
  • Use on-blur validation
  • Show errors only after field is touched
// Add validation to this form!
interface FormData {
  name: string;
  email: string;
  age: string;
}

function SignupForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    age: ''
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log('Submit:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={formData.name} onChange={handleChange} />
      <input name="email" type="email" value={formData.email} onChange={handleChange} />
      <input name="age" type="number" value={formData.age} onChange={handleChange} />
      <button type="submit">Sign Up</button>
    </form>
  );
}
πŸ’‘ Hint

Create an errors state and a touched state. Write validation functions for each field. Add onBlur handlers that validate and set errors. Only show errors if the field has been touched.

βœ… Solution
interface FormData {
  name: string;
  email: string;
  age: string;
}

interface FormErrors {
  name?: string;
  email?: string;
  age?: string;
}

function SignupForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    email: '',
    age: ''
  });

  const [errors, setErrors] = useState<FormErrors>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});

  // Validation functions
  const validateName = (name: string): string | undefined => {
    if (!name) return 'Name is required';
    if (name.length < 2) return 'Name must be at least 2 characters';
    return undefined;
  };

  const validateEmail = (email: string): string | undefined => {
    if (!email) return 'Email is required';
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return 'Invalid email format';
    }
    return undefined;
  };

  const validateAge = (age: string): string | undefined => {
    if (!age) return 'Age is required';
    const ageNum = parseInt(age);
    if (isNaN(ageNum)) return 'Age must be a number';
    if (ageNum < 18) return 'You must be at least 18 years old';
    return undefined;
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    
    // Clear error when user starts typing (if field was touched)
    if (touched[name]) {
      setErrors(prev => ({ ...prev, [name]: undefined }));
    }
  };

  const handleBlur = (field: keyof FormData) => () => {
    setTouched(prev => ({ ...prev, [field]: true }));
    
    let error: string | undefined;
    switch (field) {
      case 'name':
        error = validateName(formData.name);
        break;
      case 'email':
        error = validateEmail(formData.email);
        break;
      case 'age':
        error = validateAge(formData.age);
        break;
    }
    
    setErrors(prev => ({ ...prev, [field]: error }));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    // Mark all as touched
    setTouched({ name: true, email: true, age: true });
    
    // Validate all fields
    const newErrors: FormErrors = {
      name: validateName(formData.name),
      email: validateEmail(formData.email),
      age: validateAge(formData.age)
    };
    
    // Remove undefined errors
    Object.keys(newErrors).forEach(key => {
      if (!newErrors[key as keyof FormErrors]) {
        delete newErrors[key as keyof FormErrors];
      }
    });
    
    setErrors(newErrors);
    
    if (Object.keys(newErrors).length === 0) {
      console.log('Submit:', formData);
      alert('Form submitted successfully!');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input 
          id="name"
          name="name" 
          value={formData.name} 
          onChange={handleChange}
          onBlur={handleBlur('name')}
          aria-invalid={!!errors.name}
        />
        {errors.name && touched.name && (
          <span role="alert" style={{ color: 'red' }}>{errors.name}</span>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input 
          id="email"
          name="email" 
          type="email" 
          value={formData.email} 
          onChange={handleChange}
          onBlur={handleBlur('email')}
          aria-invalid={!!errors.email}
        />
        {errors.email && touched.email && (
          <span role="alert" style={{ color: 'red' }}>{errors.email}</span>
        )}
      </div>

      <div>
        <label htmlFor="age">Age</label>
        <input 
          id="age"
          name="age" 
          type="number" 
          value={formData.age} 
          onChange={handleChange}
          onBlur={handleBlur('age')}
          aria-invalid={!!errors.age}
        />
        {errors.age && touched.age && (
          <span role="alert" style={{ color: 'red' }}>{errors.age}</span>
        )}
      </div>

      <button type="submit">Sign Up</button>
    </form>
  );
}

❗ Error Messaging

Good error messages help users fix problems quickly. Bad error messages frustrate users and increase abandonment. Let's explore how to provide helpful, accessible error feedback.

Principles of Good Error Messages

βœ… Do's

  • Be specific - "Email must include @" not "Invalid input"
  • Be helpful - Tell users how to fix the problem
  • Be human - Use friendly, conversational language
  • Be visible - Use color, icons, and position errors near the field
  • Be accessible - Use ARIA labels and role="alert"
  • Be timely - Show errors at the right moment

❌ Don'ts

  • Don't be vague - "Error" tells users nothing
  • Don't blame - "You entered..." sounds accusatory
  • Don't use jargon - "Invalid regex pattern" confuses users
  • Don't overwhelm - Show one error at a time if possible
  • Don't hide errors - Make them easy to see

Error Message Examples

Scenario ❌ Bad βœ… Good
Empty required field "Required" "Please enter your email address"
Invalid email "Invalid" "Email must include @ and a domain (e.g., user@example.com)"
Password too short "Invalid password" "Password must be at least 8 characters long"
Passwords don't match "Error" "Passwords don't match. Please try again."
Age restriction "Invalid age" "You must be at least 18 years old to register"
Username taken "Unavailable" "This username is already taken. Try adding numbers or your name."

Error Display Patterns

There are several ways to display errors. Choose based on form complexity and user needs:

Pattern 1: Inline Errors (Most Common)

function InlineErrorExample() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState<string>();

  return (
    <div style={{ marginBottom: '1rem' }}>
      <label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
        Email
      </label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        aria-invalid={!!error}
        aria-describedby={error ? 'email-error' : undefined}
        style={{
          borderColor: error ? '#f44336' : '#ddd',
          padding: '0.5rem',
          width: '100%'
        }}
      />
      {error && (
        <div
          id="email-error"
          role="alert"
          style={{
            color: '#f44336',
            fontSize: '0.875rem',
            marginTop: '0.25rem'
          }}
        >
          ⚠️ {error}
        </div>
      )}
    </div>
  );
}

Pattern 2: Error Summary (Complex Forms)

function ErrorSummaryExample() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  return (
    <form>
      {Object.keys(errors).length > 0 && (
        <div
          role="alert"
          aria-live="polite"
          style={{
            backgroundColor: '#ffebee',
            border: '1px solid #f44336',
            borderRadius: '4px',
            padding: '1rem',
            marginBottom: '1rem'
          }}
        >
          <h3 style={{ margin: '0 0 0.5rem 0', color: '#f44336' }}>
            ⚠️ Please fix the following errors:
          </h3>
          <ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
            {Object.entries(errors).map(([field, error]) => (
              <li key={field}>
                <strong>{field}:</strong> {error}
              </li>
            ))}
          </ul>
        </div>
      )}
      
      {/* Form fields... */}
    </form>
  );
}

Pattern 3: Toast Notifications (Submission Errors)

function ToastErrorExample() {
  const [showToast, setShowToast] = useState(false);
  const [toastMessage, setToastMessage] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    try {
      await submitForm();
    } catch (error) {
      setToastMessage('Failed to submit form. Please try again.');
      setShowToast(true);
      setTimeout(() => setShowToast(false), 5000);
    }
  };

  return (
    <>
      {showToast && (
        <div
          role="alert"
          style={{
            position: 'fixed',
            top: '1rem',
            right: '1rem',
            backgroundColor: '#f44336',
            color: 'white',
            padding: '1rem',
            borderRadius: '4px',
            boxShadow: '0 2px 8px rgba(0,0,0,0.2)'
          }}
        >
          ⚠️ {toastMessage}
        </div>
      )}
      
      <form onSubmit={handleSubmit}>
        {/* Form fields... */}
      </form>
    </>
  );
}

πŸ’‘ When to Use Each Pattern

  • Inline errors - Best for most forms, show errors right next to the field
  • Error summary - Good for long forms (10+ fields) or when errors span multiple sections
  • Toast notifications - Best for server errors or actions that happen after form submission
  • Combination - Use inline + summary for complex forms

Accessibility in Error Messages

Error messages must be accessible to screen reader users:

function AccessibleErrorExample() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState<string>();
  const [touched, setTouched] = useState(false);

  return (
    <div>
      {/* Label is properly associated with input */}
      <label htmlFor="email-input">Email Address</label>
      
      <input
        id="email-input"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={() => setTouched(true)}
        
        {/* aria-invalid tells screen readers the field has an error */}
        aria-invalid={!!error && touched}
        
        {/* aria-describedby links the input to the error message */}
        aria-describedby={error && touched ? 'email-error' : undefined}
        
        {/* aria-required tells screen readers this is required */}
        aria-required="true"
      />
      
      {error && touched && (
        <div
          id="email-error"
          
          {/* role="alert" makes screen readers announce the error immediately */}
          role="alert"
          
          {/* aria-live="polite" announces changes without interrupting */}
          aria-live="polite"
          
          style={{ color: '#f44336', marginTop: '0.25rem' }}
        >
          {error}
        </div>
      )}
    </div>
  );
}

βœ… Accessibility Checklist

  • βœ… Use aria-invalid on inputs with errors
  • βœ… Use aria-describedby to link inputs to error messages
  • βœ… Use role="alert" on error messages
  • βœ… Use aria-live="polite" for dynamic error updates
  • βœ… Use aria-required for required fields
  • βœ… Ensure error messages have sufficient color contrast (4.5:1)
  • βœ… Don't rely on color alone - use icons and text
  • βœ… Make sure error messages are keyboard accessible
πŸ‹οΈ Exercise 3: Improve Error Messages

Task: Improve the error messages in this form to be more helpful and accessible.

// Current implementation (needs improvement!)
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    const newErrors: Record<string, string> = {};
    
    if (!email) newErrors.email = 'Required';
    if (!password) newErrors.password = 'Required';
    if (password && password.length < 8) newErrors.password = 'Too short';
    
    setErrors(newErrors);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      {errors.email && <span>{errors.email}</span>}
      
      <input 
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {errors.password && <span>{errors.password}</span>}
      
      <button type="submit">Login</button>
    </form>
  );
}
πŸ’‘ Hint

Improve: 1) Error message text to be more helpful, 2) Add proper labels, 3) Add ARIA attributes for accessibility, 4) Style errors to be more visible, 5) Add icons to errors

βœ… Solution
function ImprovedLoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});

  const handleBlur = (field: string) => () => {
    setTouched(prev => ({ ...prev, [field]: true }));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    // Mark all as touched
    setTouched({ email: true, password: true });
    
    const newErrors: Record<string, string> = {};
    
    // Improved error messages - specific and helpful
    if (!email) {
      newErrors.email = 'Please enter your email address';
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      newErrors.email = 'Please enter a valid email address (e.g., user@example.com)';
    }
    
    if (!password) {
      newErrors.password = 'Please enter your password';
    } else if (password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters long';
    }
    
    setErrors(newErrors);
    
    if (Object.keys(newErrors).length === 0) {
      console.log('Login successful!');
    }
  };

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '400px' }}>
      <div style={{ marginBottom: '1rem' }}>
        {/* Proper label */}
        <label 
          htmlFor="email-input" 
          style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}
        >
          Email Address
        </label>
        
        <input 
          id="email-input"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          onBlur={handleBlur('email')}
          
          {/* Accessibility attributes */}
          aria-invalid={!!errors.email && touched.email}
          aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
          aria-required="true"
          
          {/* Visual styling */}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `2px solid ${errors.email && touched.email ? '#f44336' : '#ddd'}`,
            borderRadius: '4px',
            fontSize: '1rem'
          }}
        />
        
        {/* Improved error display */}
        {errors.email && touched.email && (
          <div
            id="email-error"
            role="alert"
            aria-live="polite"
            style={{
              color: '#f44336',
              fontSize: '0.875rem',
              marginTop: '0.25rem',
              display: 'flex',
              alignItems: 'center',
              gap: '0.25rem'
            }}
          >
            <span aria-hidden="true">⚠️</span>
            {errors.email}
          </div>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label 
          htmlFor="password-input" 
          style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}
        >
          Password
        </label>
        
        <input 
          id="password-input"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          onBlur={handleBlur('password')}
          aria-invalid={!!errors.password && touched.password}
          aria-describedby={errors.password && touched.password ? 'password-error' : undefined}
          aria-required="true"
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `2px solid ${errors.password && touched.password ? '#f44336' : '#ddd'}`,
            borderRadius: '4px',
            fontSize: '1rem'
          }}
        />
        
        {errors.password && touched.password && (
          <div
            id="password-error"
            role="alert"
            aria-live="polite"
            style={{
              color: '#f44336',
              fontSize: '0.875rem',
              marginTop: '0.25rem',
              display: 'flex',
              alignItems: 'center',
              gap: '0.25rem'
            }}
          >
            <span aria-hidden="true">⚠️</span>
            {errors.password}
          </div>
        )}
      </div>

      <button 
        type="submit"
        style={{
          width: '100%',
          padding: '0.75rem',
          backgroundColor: '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          fontSize: '1rem',
          fontWeight: '500',
          cursor: 'pointer'
        }}
      >
        Login
      </button>
    </form>
  );
}

βœ… Error Message Checklist

  • βœ… Specific - Explains exactly what's wrong
  • βœ… Helpful - Tells user how to fix it
  • βœ… Friendly - Uses conversational, positive language
  • βœ… Visible - Easy to see with color and icons
  • βœ… Accessible - Works with screen readers
  • βœ… Timely - Appears at the right moment
  • βœ… Consistent - Same style throughout form

πŸ”„ Dynamic Forms & Field Arrays

Many real-world forms need dynamic fieldsβ€”allowing users to add or remove items like phone numbers, work experiences, or family members. Let's learn how to handle these dynamic field arrays effectively.

The Challenge of Dynamic Fields

Consider a job application where users can add multiple work experiences. Each experience has several fields (company, position, dates, description), and users should be able to add or remove experiences as needed.

graph TB A[Dynamic Form] --> B[Add Item] A --> C[Remove Item] A --> D[Edit Item] A --> E[Reorder Items] B --> B1[Push to array] B --> B2[Generate unique ID] C --> C1[Filter array] C --> C2[Update indices] D --> D1[Update at index] D --> D2[Validate item] E --> E1[Drag and drop] E --> E2[Move up/down buttons] style A fill:#667eea,color:#fff style B fill:#48bb78,color:#fff style C fill:#f56565,color:#fff style D fill:#ed8936,color:#fff

Basic Field Array Implementation

Let's start with a simple exampleβ€”managing multiple phone numbers:

interface PhoneNumber {
  id: string;
  type: 'mobile' | 'home' | 'work';
  number: string;
}

function PhoneNumbersForm() {
  const [phones, setPhones] = useState<PhoneNumber[]>([
    { id: crypto.randomUUID(), type: 'mobile', number: '' }
  ]);

  // Add a new phone number
  const addPhone = () => {
    setPhones(prev => [
      ...prev,
      { id: crypto.randomUUID(), type: 'mobile', number: '' }
    ]);
  };

  // Remove a phone number by ID
  const removePhone = (id: string) => {
    setPhones(prev => prev.filter(phone => phone.id !== id));
  };

  // Update a specific phone number
  const updatePhone = (id: string, field: keyof PhoneNumber, value: string) => {
    setPhones(prev => prev.map(phone =>
      phone.id === id
        ? { ...phone, [field]: value }
        : phone
    ));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log('Phones:', phones);
  };

  return (
    <form onSubmit={handleSubmit}>
      <h3>Phone Numbers</h3>
      
      {phones.map((phone, index) => (
        <div 
          key={phone.id}
          style={{
            border: '1px solid #ddd',
            padding: '1rem',
            marginBottom: '1rem',
            borderRadius: '4px'
          }}
        >
          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
            <strong>Phone {index + 1}</strong>
            {phones.length > 1 && (
              <button
                type="button"
                onClick={() => removePhone(phone.id)}
                style={{
                  background: '#f56565',
                  color: 'white',
                  border: 'none',
                  padding: '0.25rem 0.5rem',
                  borderRadius: '4px',
                  cursor: 'pointer'
                }}
              >
                Remove
              </button>
            )}
          </div>

          <div style={{ display: 'grid', gap: '0.5rem' }}>
            <select
              value={phone.type}
              onChange={(e) => updatePhone(phone.id, 'type', e.target.value)}
              style={{ padding: '0.5rem' }}
            >
              <option value="mobile">Mobile</option>
              <option value="home">Home</option>
              <option value="work">Work</option>
            </select>

            <input
              type="tel"
              value={phone.number}
              onChange={(e) => updatePhone(phone.id, 'number', e.target.value)}
              placeholder="(555) 123-4567"
              style={{ padding: '0.5rem' }}
            />
          </div>
        </div>
      ))}

      <button
        type="button"
        onClick={addPhone}
        style={{
          background: '#48bb78',
          color: 'white',
          border: 'none',
          padding: '0.5rem 1rem',
          borderRadius: '4px',
          cursor: 'pointer',
          marginBottom: '1rem'
        }}
      >
        + Add Phone Number
      </button>

      <button type="submit" style={{ marginLeft: '1rem' }}>
        Submit
      </button>
    </form>
  );
}

πŸ’‘ Key Concepts in Field Arrays

  • Unique IDs - Use crypto.randomUUID() or nanoid, never array index
  • Immutable updates - Always create new arrays with map/filter/concat
  • Map over arrays - Render each item with its data
  • Partial updates - Update only the changed item, not the entire array
  • Key prop - Use stable ID, not index, for React reconciliation

⚠️ Common Mistakes with Field Arrays

  • Using array index as key - Causes bugs when items are reordered or removed
  • Mutating arrays directly - push(), splice() etc. won't trigger re-renders
  • Not preventing minimum items - Allow users to remove all items accidentally
  • Poor UX for empty state - Show helpful message when array is empty

Complex Field Array: Work Experience

Let's build a more complex example with nested data and validation:

interface WorkExperience {
  id: string;
  company: string;
  position: string;
  startDate: string;
  endDate: string;
  current: boolean;
  description: string;
}

interface WorkExperienceErrors {
  company?: string;
  position?: string;
  startDate?: string;
  endDate?: string;
}

function WorkExperienceForm() {
  const [experiences, setExperiences] = useState<WorkExperience[]>([
    {
      id: crypto.randomUUID(),
      company: '',
      position: '',
      startDate: '',
      endDate: '',
      current: false,
      description: ''
    }
  ]);

  const [errors, setErrors] = useState<Record<string, WorkExperienceErrors>>({});

  // Add new experience
  const addExperience = () => {
    const newExp: WorkExperience = {
      id: crypto.randomUUID(),
      company: '',
      position: '',
      startDate: '',
      endDate: '',
      current: false,
      description: ''
    };
    setExperiences(prev => [...prev, newExp]);
  };

  // Remove experience
  const removeExperience = (id: string) => {
    setExperiences(prev => prev.filter(exp => exp.id !== id));
    // Also remove errors for this experience
    setErrors(prev => {
      const newErrors = { ...prev };
      delete newErrors[id];
      return newErrors;
    });
  };

  // Update experience field
  const updateExperience = (
    id: string, 
    field: keyof WorkExperience, 
    value: string | boolean
  ) => {
    setExperiences(prev => prev.map(exp =>
      exp.id === id ? { ...exp, [field]: value } : exp
    ));

    // Clear error when user starts typing
    if (errors[id]?.[field as keyof WorkExperienceErrors]) {
      setErrors(prev => ({
        ...prev,
        [id]: {
          ...prev[id],
          [field]: undefined
        }
      }));
    }
  };

  // Validate a single experience
  const validateExperience = (exp: WorkExperience): WorkExperienceErrors => {
    const expErrors: WorkExperienceErrors = {};

    if (!exp.company.trim()) {
      expErrors.company = 'Company name is required';
    }

    if (!exp.position.trim()) {
      expErrors.position = 'Position is required';
    }

    if (!exp.startDate) {
      expErrors.startDate = 'Start date is required';
    }

    if (!exp.current && !exp.endDate) {
      expErrors.endDate = 'End date is required (or mark as current position)';
    }

    if (exp.startDate && exp.endDate && exp.startDate > exp.endDate) {
      expErrors.endDate = 'End date must be after start date';
    }

    return expErrors;
  };

  // Validate all experiences
  const validateAll = (): boolean => {
    const allErrors: Record<string, WorkExperienceErrors> = {};
    let hasErrors = false;

    experiences.forEach(exp => {
      const expErrors = validateExperience(exp);
      if (Object.keys(expErrors).length > 0) {
        allErrors[exp.id] = expErrors;
        hasErrors = true;
      }
    });

    setErrors(allErrors);
    return !hasErrors;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    if (validateAll()) {
      console.log('Valid experiences:', experiences);
      alert('Form submitted successfully!');
    } else {
      alert('Please fix the errors before submitting');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Work Experience</h2>
      
      {experiences.map((exp, index) => (
        <div
          key={exp.id}
          style={{
            border: '2px solid #ddd',
            padding: '1.5rem',
            marginBottom: '1.5rem',
            borderRadius: '8px',
            backgroundColor: '#f9f9f9'
          }}
        >
          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
            <h3 style={{ margin: 0 }}>Experience {index + 1}</h3>
            {experiences.length > 1 && (
              <button
                type="button"
                onClick={() => removeExperience(exp.id)}
                style={{
                  background: '#f56565',
                  color: 'white',
                  border: 'none',
                  padding: '0.5rem 1rem',
                  borderRadius: '4px',
                  cursor: 'pointer'
                }}
              >
                Remove
              </button>
            )}
          </div>

          <div style={{ display: 'grid', gap: '1rem' }}>
            {/* Company */}
            <div>
              <label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: '500' }}>
                Company *
              </label>
              <input
                type="text"
                value={exp.company}
                onChange={(e) => updateExperience(exp.id, 'company', e.target.value)}
                placeholder="Acme Corp"
                style={{
                  width: '100%',
                  padding: '0.5rem',
                  border: `1px solid ${errors[exp.id]?.company ? '#f56565' : '#ddd'}`,
                  borderRadius: '4px'
                }}
              />
              {errors[exp.id]?.company && (
                <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                  {errors[exp.id].company}
                </span>
              )}
            </div>

            {/* Position */}
            <div>
              <label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: '500' }}>
                Position *
              </label>
              <input
                type="text"
                value={exp.position}
                onChange={(e) => updateExperience(exp.id, 'position', e.target.value)}
                placeholder="Software Engineer"
                style={{
                  width: '100%',
                  padding: '0.5rem',
                  border: `1px solid ${errors[exp.id]?.position ? '#f56565' : '#ddd'}`,
                  borderRadius: '4px'
                }}
              />
              {errors[exp.id]?.position && (
                <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                  {errors[exp.id].position}
                </span>
              )}
            </div>

            {/* Date range */}
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
              <div>
                <label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: '500' }}>
                  Start Date *
                </label>
                <input
                  type="month"
                  value={exp.startDate}
                  onChange={(e) => updateExperience(exp.id, 'startDate', e.target.value)}
                  style={{
                    width: '100%',
                    padding: '0.5rem',
                    border: `1px solid ${errors[exp.id]?.startDate ? '#f56565' : '#ddd'}`,
                    borderRadius: '4px'
                  }}
                />
                {errors[exp.id]?.startDate && (
                  <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                    {errors[exp.id].startDate}
                  </span>
                )}
              </div>

              <div>
                <label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: '500' }}>
                  End Date {!exp.current && '*'}
                </label>
                <input
                  type="month"
                  value={exp.endDate}
                  onChange={(e) => updateExperience(exp.id, 'endDate', e.target.value)}
                  disabled={exp.current}
                  style={{
                    width: '100%',
                    padding: '0.5rem',
                    border: `1px solid ${errors[exp.id]?.endDate ? '#f56565' : '#ddd'}`,
                    borderRadius: '4px',
                    opacity: exp.current ? 0.5 : 1
                  }}
                />
                {errors[exp.id]?.endDate && (
                  <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                    {errors[exp.id].endDate}
                  </span>
                )}
              </div>
            </div>

            {/* Current position checkbox */}
            <div>
              <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
                <input
                  type="checkbox"
                  checked={exp.current}
                  onChange={(e) => {
                    updateExperience(exp.id, 'current', e.target.checked);
                    if (e.target.checked) {
                      updateExperience(exp.id, 'endDate', '');
                    }
                  }}
                />
                <span>I currently work here</span>
              </label>
            </div>

            {/* Description */}
            <div>
              <label style={{ display: 'block', marginBottom: '0.25rem', fontWeight: '500' }}>
                Description (Optional)
              </label>
              <textarea
                value={exp.description}
                onChange={(e) => updateExperience(exp.id, 'description', e.target.value)}
                placeholder="Describe your responsibilities and achievements..."
                rows={3}
                style={{
                  width: '100%',
                  padding: '0.5rem',
                  border: '1px solid #ddd',
                  borderRadius: '4px',
                  fontFamily: 'inherit',
                  resize: 'vertical'
                }}
              />
            </div>
          </div>
        </div>
      ))}

      <button
        type="button"
        onClick={addExperience}
        style={{
          background: '#48bb78',
          color: 'white',
          border: 'none',
          padding: '0.75rem 1.5rem',
          borderRadius: '4px',
          cursor: 'pointer',
          fontSize: '1rem',
          fontWeight: '500',
          marginRight: '1rem'
        }}
      >
        + Add Work Experience
      </button>

      <button
        type="submit"
        style={{
          background: '#667eea',
          color: 'white',
          border: 'none',
          padding: '0.75rem 1.5rem',
          borderRadius: '4px',
          cursor: 'pointer',
          fontSize: '1rem',
          fontWeight: '500'
        }}
      >
        Submit Application
      </button>
    </form>
  );
}

βœ… Advanced Field Array Techniques

  • Per-item validation - Track errors for each item separately
  • Conditional fields - End date disabled when "current" is checked
  • Cross-field validation - Start date must be before end date
  • Minimum items - Always keep at least one experience
  • Clear feedback - Show which experience has errors
  • Good UX - Clear add/remove buttons, numbered items

Field Array with Drag and Drop Reordering

For even better UX, allow users to reorder items:

function ReorderableList() {
  const [items, setItems] = useState([
    { id: '1', text: 'First item' },
    { id: '2', text: 'Second item' },
    { id: '3', text: 'Third item' }
  ]);

  // Move item up
  const moveUp = (index: number) => {
    if (index === 0) return; // Already at top
    
    setItems(prev => {
      const newItems = [...prev];
      [newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];
      return newItems;
    });
  };

  // Move item down
  const moveDown = (index: number) => {
    if (index === items.length - 1) return; // Already at bottom
    
    setItems(prev => {
      const newItems = [...prev];
      [newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]];
      return newItems;
    });
  };

  return (
    <div>
      {items.map((item, index) => (
        <div
          key={item.id}
          style={{
            display: 'flex',
            alignItems: 'center',
            gap: '1rem',
            padding: '0.5rem',
            border: '1px solid #ddd',
            marginBottom: '0.5rem',
            borderRadius: '4px'
          }}
        >
          <div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
            <button
              onClick={() => moveUp(index)}
              disabled={index === 0}
              style={{
                padding: '0.25rem 0.5rem',
                fontSize: '0.75rem',
                cursor: index === 0 ? 'not-allowed' : 'pointer',
                opacity: index === 0 ? 0.5 : 1
              }}
            >
              β–²
            </button>
            <button
              onClick={() => moveDown(index)}
              disabled={index === items.length - 1}
              style={{
                padding: '0.25rem 0.5rem',
                fontSize: '0.75rem',
                cursor: index === items.length - 1 ? 'not-allowed' : 'pointer',
                opacity: index === items.length - 1 ? 0.5 : 1
              }}
            >
              β–Ό
            </button>
          </div>
          
          <span>{item.text}</span>
        </div>
      ))}
    </div>
  );
}
πŸ‹οΈ Exercise 4: Build an Education Field Array

Task: Create a dynamic field array for education history with these requirements:

  • Fields: School, Degree, Field of Study, Graduation Year
  • Allow adding/removing education entries (minimum 1)
  • Validate that all required fields are filled
  • Graduation year must be between 1950 and current year + 10
  • Show clear error messages for each field
πŸ’‘ Hint

Start with the work experience example above. Create an Education interface with id, school, degree, field, and year. Create validation functions for each field. Map over the array to render each entry with its own set of inputs and error messages.

βœ… Solution
interface Education {
  id: string;
  school: string;
  degree: string;
  field: string;
  year: string;
}

interface EducationErrors {
  school?: string;
  degree?: string;
  field?: string;
  year?: string;
}

function EducationForm() {
  const [education, setEducation] = useState<Education[]>([
    {
      id: crypto.randomUUID(),
      school: '',
      degree: '',
      field: '',
      year: ''
    }
  ]);

  const [errors, setErrors] = useState<Record<string, EducationErrors>>({});

  const addEducation = () => {
    setEducation(prev => [
      ...prev,
      {
        id: crypto.randomUUID(),
        school: '',
        degree: '',
        field: '',
        year: ''
      }
    ]);
  };

  const removeEducation = (id: string) => {
    setEducation(prev => prev.filter(edu => edu.id !== id));
    setErrors(prev => {
      const newErrors = { ...prev };
      delete newErrors[id];
      return newErrors;
    });
  };

  const updateEducation = (id: string, field: keyof Education, value: string) => {
    setEducation(prev => prev.map(edu =>
      edu.id === id ? { ...edu, [field]: value } : edu
    ));

    // Clear error when user types
    if (errors[id]?.[field as keyof EducationErrors]) {
      setErrors(prev => ({
        ...prev,
        [id]: {
          ...prev[id],
          [field]: undefined
        }
      }));
    }
  };

  const validateEducation = (edu: Education): EducationErrors => {
    const eduErrors: EducationErrors = {};
    const currentYear = new Date().getFullYear();

    if (!edu.school.trim()) {
      eduErrors.school = 'School name is required';
    }

    if (!edu.degree.trim()) {
      eduErrors.degree = 'Degree is required';
    }

    if (!edu.field.trim()) {
      eduErrors.field = 'Field of study is required';
    }

    if (!edu.year) {
      eduErrors.year = 'Graduation year is required';
    } else {
      const year = parseInt(edu.year);
      if (isNaN(year)) {
        eduErrors.year = 'Year must be a number';
      } else if (year < 1950) {
        eduErrors.year = 'Year must be 1950 or later';
      } else if (year > currentYear + 10) {
        eduErrors.year = `Year cannot be more than ${currentYear + 10}`;
      }
    }

    return eduErrors;
  };

  const validateAll = (): boolean => {
    const allErrors: Record<string, EducationErrors> = {};
    let hasErrors = false;

    education.forEach(edu => {
      const eduErrors = validateEducation(edu);
      if (Object.keys(eduErrors).length > 0) {
        allErrors[edu.id] = eduErrors;
        hasErrors = true;
      }
    });

    setErrors(allErrors);
    return !hasErrors;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    if (validateAll()) {
      console.log('Valid education:', education);
      alert('Form submitted successfully!');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Education History</h2>

      {education.map((edu, index) => (
        <div
          key={edu.id}
          style={{
            border: '2px solid #ddd',
            padding: '1.5rem',
            marginBottom: '1.5rem',
            borderRadius: '8px'
          }}
        >
          <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
            <h3>Education {index + 1}</h3>
            {education.length > 1 && (
              <button
                type="button"
                onClick={() => removeEducation(edu.id)}
                style={{
                  background: '#f56565',
                  color: 'white',
                  border: 'none',
                  padding: '0.5rem 1rem',
                  borderRadius: '4px',
                  cursor: 'pointer'
                }}
              >
                Remove
              </button>
            )}
          </div>

          <div style={{ display: 'grid', gap: '1rem' }}>
            <div>
              <label>School *</label>
              <input
                type="text"
                value={edu.school}
                onChange={(e) => updateEducation(edu.id, 'school', e.target.value)}
                placeholder="University of Example"
                style={{
                  width: '100%',
                  padding: '0.5rem',
                  border: `1px solid ${errors[edu.id]?.school ? '#f56565' : '#ddd'}`
                }}
              />
              {errors[edu.id]?.school && (
                <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                  {errors[edu.id].school}
                </span>
              )}
            </div>

            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
              <div>
                <label>Degree *</label>
                <input
                  type="text"
                  value={edu.degree}
                  onChange={(e) => updateEducation(edu.id, 'degree', e.target.value)}
                  placeholder="Bachelor of Science"
                  style={{
                    width: '100%',
                    padding: '0.5rem',
                    border: `1px solid ${errors[edu.id]?.degree ? '#f56565' : '#ddd'}`
                  }}
                />
                {errors[edu.id]?.degree && (
                  <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                    {errors[edu.id].degree}
                  </span>
                )}
              </div>

              <div>
                <label>Field of Study *</label>
                <input
                  type="text"
                  value={edu.field}
                  onChange={(e) => updateEducation(edu.id, 'field', e.target.value)}
                  placeholder="Computer Science"
                  style={{
                    width: '100%',
                    padding: '0.5rem',
                    border: `1px solid ${errors[edu.id]?.field ? '#f56565' : '#ddd'}`
                  }}
                />
                {errors[edu.id]?.field && (
                  <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                    {errors[edu.id].field}
                  </span>
                )}
              </div>
            </div>

            <div>
              <label>Graduation Year *</label>
              <input
                type="number"
                value={edu.year}
                onChange={(e) => updateEducation(edu.id, 'year', e.target.value)}
                placeholder="2020"
                min="1950"
                max={new Date().getFullYear() + 10}
                style={{
                  width: '100%',
                  padding: '0.5rem',
                  border: `1px solid ${errors[edu.id]?.year ? '#f56565' : '#ddd'}`
                }}
              />
              {errors[edu.id]?.year && (
                <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                  {errors[edu.id].year}
                </span>
              )}
            </div>
          </div>
        </div>
      ))}

      <button
        type="button"
        onClick={addEducation}
        style={{
          background: '#48bb78',
          color: 'white',
          padding: '0.75rem 1.5rem',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer',
          marginRight: '1rem'
        }}
      >
        + Add Education
      </button>

      <button
        type="submit"
        style={{
          background: '#667eea',
          color: 'white',
          padding: '0.75rem 1.5rem',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        Submit
      </button>
    </form>
  );
}

⚑ Form Performance Optimization

Large forms can become slow if every keystroke causes the entire form to re-render. Let's explore techniques to keep forms fast and responsive.

The Performance Problem

In a typical form, every input change triggers a state update, which causes the entire form component to re-render. With many fields, this can feel sluggish:

// Every keystroke in ANY field re-renders ENTIRE form
function SlowForm() {
  const [formData, setFormData] = useState({ 
    field1: '', 
    field2: '', 
    /* ... 20 more fields */ 
  });

  return (
    <form>
      <input value={formData.field1} onChange={...} />
      <input value={formData.field2} onChange={...} />
      {/* ... 20 more inputs all re-render on every change */}
    </form>
  );
}
graph LR A[User types in field 1] --> B[State updates] B --> C[Entire form re-renders] C --> D[All 20+ inputs re-render] D --> E[React reconciles DOM] E --> F[Browser repaints] style A fill:#667eea,color:#fff style D fill:#f56565,color:#fff style F fill:#48bb78,color:#fff

πŸ”„ Interactive: Form Re-render Visualization

Watch which components re-render when you type - unoptimized vs optimized forms

Type in Field 1 and Watch What Re-renders ❌ Unoptimized Form Single useState object Field 1: ← typing Field 2: john@email.com Field 3: 555-1234 Field 4: 123 Main St Re-renders: 0 βœ… Optimized Form React.memo + useCallback Field 1: ← typing Field 2: john@email.com Field 3: 555-1234 Field 4: 123 Main St Re-renders: 0 Re-rendered Unchanged

Notice how the unoptimized form re-renders ALL fields, while the optimized form only re-renders Field 1

Solution 1: Split into Smaller Components

Break the form into smaller components so only the changed field re-renders:

// Each field is isolated in its own component
interface FieldProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
  error?: string;
}

// This component only re-renders when ITS value changes
const FormField = React.memo(({ label, value, onChange, error }: FieldProps) => {
  console.log(`Rendering ${label}`);
  
  return (
    <div>
      <label>{label}</label>
      <input
        value={value}
        onChange={(e) => onChange(e.target.value)}
        style={{ border: error ? '1px solid red' : '1px solid #ddd' }}
      />
      {error && <span style={{ color: 'red' }}>{error}</span>}
    </div>
  );
});

function OptimizedForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    phone: ''
  });

  const handleChange = (field: keyof typeof formData) => (value: string) => {
    setFormData(prev => ({ ...prev, [field]: value }));
  };

  return (
    <form>
      <FormField
        label="First Name"
        value={formData.firstName}
        onChange={handleChange('firstName')}
      />
      <FormField
        label="Last Name"
        value={formData.lastName}
        onChange={handleChange('lastName')}
      />
      <FormField
        label="Email"
        value={formData.email}
        onChange={handleChange('email')}
      />
      <FormField
        label="Phone"
        value={formData.phone}
        onChange={handleChange('phone')}
      />
    </form>
  );
}

πŸ’‘ How React.memo Helps

React.memo prevents re-renders if props haven't changed. When you type in "First Name":

  • βœ… Only FirstName's FormField re-renders
  • ❌ LastName, Email, Phone FormFields do NOT re-render
  • Result: 75% fewer component renders!

Solution 2: Uncontrolled Inputs with useRef

For some forms, you don't need the value in state until submission. Use refs instead:

function UncontrolledForm() {
  const nameRef = useRef<HTMLInputElement>(null);
  const emailRef = useRef<HTMLInputElement>(null);
  const phoneRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const formData = {
      name: nameRef.current?.value || '',
      email: emailRef.current?.value || '',
      phone: phoneRef.current?.value || ''
    };

    console.log('Form data:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input 
        ref={nameRef} 
        defaultValue="" 
        placeholder="Name"
      />
      <input 
        ref={emailRef} 
        defaultValue="" 
        placeholder="Email"
      />
      <input 
        ref={phoneRef} 
        defaultValue="" 
        placeholder="Phone"
      />
      <button type="submit">Submit</button>
    </form>
  );
}

βœ… When to Use Uncontrolled Inputs

  • Simple forms where you only need data on submit
  • Forms with many fields where real-time validation isn't needed
  • Integration with non-React libraries
  • File inputs (which must be uncontrolled)

⚠️ Limitations of Uncontrolled Inputs

  • Cannot show real-time validation
  • Cannot conditionally enable/disable submit based on values
  • Cannot show character counts or format as user types
  • Cannot implement field dependencies easily

Solution 3: Debounced State Updates

Update state only after the user stops typing, not on every keystroke:

import { useState, useEffect } from 'react';

function useDebouncedValue<T>(value: T, delay: number = 500): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

function DebouncedSearchForm() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebouncedValue(searchTerm, 300);

  // This only runs when user stops typing for 300ms
  useEffect(() => {
    if (debouncedSearch) {
      console.log('Searching for:', debouncedSearch);
      // Perform expensive search/validation here
    }
  }, [debouncedSearch]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      <p>Searching for: {debouncedSearch}</p>
    </div>
  );
}

πŸ’‘ When to Use Debouncing

  • Search inputs that trigger API calls
  • Username availability checks
  • Address autocomplete
  • Expensive validation (e.g., checking against large lists)
  • Any operation where real-time is nice but not critical

Solution 4: useCallback for Stable Function References

Prevent child components from re-rendering by memoizing callback functions:

import { useCallback, memo } from 'react';

interface FieldProps {
  value: string;
  onChange: (value: string) => void;
}

const ExpensiveField = memo(({ value, onChange }: FieldProps) => {
  console.log('ExpensiveField render');
  // Imagine this has expensive rendering logic
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
});

function FormWithCallback() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  // Without useCallback, this creates a NEW function on every render
  // causing ExpensiveField to re-render even with memo
  const handleNameChange = useCallback((value: string) => {
    setName(value);
  }, []); // Empty deps = function never changes

  // This one DOES change when email changes (if needed for validation)
  const handleEmailChange = useCallback((value: string) => {
    setEmail(value);
  }, [email]);

  return (
    <form>
      <ExpensiveField value={name} onChange={handleNameChange} />
      <ExpensiveField value={email} onChange={handleEmailChange} />
    </form>
  );
}

βœ… Performance Optimization Checklist

  • βœ… Split large forms into smaller components
  • βœ… Use React.memo on field components
  • βœ… Use useCallback for event handlers passed to memoized components
  • βœ… Consider uncontrolled inputs for simple forms
  • βœ… Debounce expensive operations (API calls, validation)
  • βœ… Avoid inline object/function creation in render
  • βœ… Use keys properly in field arrays (ID, not index)
  • βœ… Profile with React DevTools to find actual bottlenecks
πŸ‹οΈ Exercise 5: Optimize a Slow Form

Task: This form re-renders all fields on every keystroke. Optimize it using React.memo and useCallback.

// This form is slow! Optimize it.
function SlowRegistrationForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    phone: '',
    address: '',
    city: '',
    state: '',
    zip: ''
  });

  const handleChange = (field: string, value: string) => {
    setFormData(prev => ({ ...prev, [field]: value }));
  };

  return (
    <form>
      {Object.keys(formData).map(field => (
        <div key={field}>
          <label>{field}</label>
          <input
            value={formData[field as keyof typeof formData]}
            onChange={(e) => handleChange(field, e.target.value)}
          />
        </div>
      ))}
      <button type="submit">Submit</button>
    </form>
  );
}
πŸ’‘ Hint

Create a memoized FormField component. Use useCallback for the handleChange function to ensure it doesn't create a new function on every render.

βœ… Solution
import { useState, useCallback, memo } from 'react';

interface FormFieldProps {
  label: string;
  value: string;
  onChange: (value: string) => void;
}

// Memoized field component
const FormField = memo(({ label, value, onChange }: FormFieldProps) => {
  console.log(`Rendering ${label}`);
  
  return (
    <div style={{ marginBottom: '1rem' }}>
      <label style={{ display: 'block', marginBottom: '0.25rem' }}>
        {label}
      </label>
      <input
        value={value}
        onChange={(e) => onChange(e.target.value)}
        style={{ width: '100%', padding: '0.5rem' }}
      />
    </div>
  );
});

function OptimizedRegistrationForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
    phone: '',
    address: '',
    city: '',
    state: '',
    zip: ''
  });

  // Create stable change handler for each field
  const createChangeHandler = useCallback((field: keyof typeof formData) => {
    return (value: string) => {
      setFormData(prev => ({ ...prev, [field]: value }));
    };
  }, []); // Empty deps - this function never changes

  // Create all handlers once
  const handlers = {
    firstName: createChangeHandler('firstName'),
    lastName: createChangeHandler('lastName'),
    email: createChangeHandler('email'),
    phone: createChangeHandler('phone'),
    address: createChangeHandler('address'),
    city: createChangeHandler('city'),
    state: createChangeHandler('state'),
    zip: createChangeHandler('zip')
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log('Form data:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <FormField
        label="First Name"
        value={formData.firstName}
        onChange={handlers.firstName}
      />
      <FormField
        label="Last Name"
        value={formData.lastName}
        onChange={handlers.lastName}
      />
      <FormField
        label="Email"
        value={formData.email}
        onChange={handlers.email}
      />
      <FormField
        label="Phone"
        value={formData.phone}
        onChange={handlers.phone}
      />
      <FormField
        label="Address"
        value={formData.address}
        onChange={handlers.address}
      />
      <FormField
        label="City"
        value={formData.city}
        onChange={handlers.city}
      />
      <FormField
        label="State"
        value={formData.state}
        onChange={handlers.state}
      />
      <FormField
        label="Zip"
        value={formData.zip}
        onChange={handlers.zip}
      />

      <button 
        type="submit"
        style={{
          padding: '0.75rem 1.5rem',
          background: '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: 'pointer'
        }}
      >
        Submit
      </button>
    </form>
  );
}

πŸš€ Advanced Patterns

Now that we've covered the fundamentals, let's explore some advanced patterns that solve specific complex form scenarios.

Multi-Step Forms (Wizards)

Multi-step forms break complex forms into manageable chunks. Users complete one step at a time, with the ability to go back and forth:

type Step = 'personal' | 'contact' | 'preferences' | 'review';

interface FormData {
  // Personal
  firstName: string;
  lastName: string;
  birthDate: string;
  
  // Contact
  email: string;
  phone: string;
  address: string;
  
  // Preferences
  newsletter: boolean;
  notifications: boolean;
  theme: 'light' | 'dark';
}

function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState<Step>('personal');
  const [formData, setFormData] = useState<FormData>({
    firstName: '',
    lastName: '',
    birthDate: '',
    email: '',
    phone: '',
    address: '',
    newsletter: false,
    notifications: true,
    theme: 'light'
  });

  const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});

  const steps: Step[] = ['personal', 'contact', 'preferences', 'review'];
  const currentStepIndex = steps.indexOf(currentStep);

  // Validation for each step
  const validateStep = (step: Step): boolean => {
    const newErrors: Partial<Record<keyof FormData, string>> = {};

    switch (step) {
      case 'personal':
        if (!formData.firstName) newErrors.firstName = 'First name is required';
        if (!formData.lastName) newErrors.lastName = 'Last name is required';
        if (!formData.birthDate) newErrors.birthDate = 'Birth date is required';
        break;
      
      case 'contact':
        if (!formData.email) newErrors.email = 'Email is required';
        if (!formData.phone) newErrors.phone = 'Phone is required';
        if (!formData.address) newErrors.address = 'Address is required';
        break;
      
      // Preferences step has no required fields
      case 'preferences':
        break;
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleNext = () => {
    if (validateStep(currentStep)) {
      const nextIndex = currentStepIndex + 1;
      if (nextIndex < steps.length) {
        setCurrentStep(steps[nextIndex]);
      }
    }
  };

  const handleBack = () => {
    const prevIndex = currentStepIndex - 1;
    if (prevIndex >= 0) {
      setCurrentStep(steps[prevIndex]);
    }
  };

  const handleSubmit = () => {
    console.log('Final form data:', formData);
    alert('Form submitted successfully!');
  };

  const updateField = (field: keyof FormData, value: any) => {
    setFormData(prev => ({ ...prev, [field]: value }));
    // Clear error when user types
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: undefined }));
    }
  };

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      {/* Progress indicator */}
      <div style={{ marginBottom: '2rem' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
          {steps.map((step, index) => (
            <div
              key={step}
              style={{
                flex: 1,
                height: '4px',
                backgroundColor: index <= currentStepIndex ? '#667eea' : '#ddd',
                marginRight: index < steps.length - 1 ? '0.5rem' : 0
              }}
            />
          ))}
        </div>
        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
          {steps.map((step, index) => (
            <span
              key={step}
              style={{
                fontSize: '0.875rem',
                color: index <= currentStepIndex ? '#667eea' : '#999',
                fontWeight: index === currentStepIndex ? 'bold' : 'normal'
              }}
            >
              {step.charAt(0).toUpperCase() + step.slice(1)}
            </span>
          ))}
        </div>
      </div>

      {/* Step content */}
      <div style={{ minHeight: '300px', marginBottom: '2rem' }}>
        {currentStep === 'personal' && (
          <div>
            <h2>Personal Information</h2>
            <div style={{ display: 'grid', gap: '1rem' }}>
              <div>
                <label>First Name *</label>
                <input
                  type="text"
                  value={formData.firstName}
                  onChange={(e) => updateField('firstName', e.target.value)}
                  style={{ width: '100%', padding: '0.5rem' }}
                />
                {errors.firstName && <span style={{ color: 'red' }}>{errors.firstName}</span>}
              </div>
              
              <div>
                <label>Last Name *</label>
                <input
                  type="text"
                  value={formData.lastName}
                  onChange={(e) => updateField('lastName', e.target.value)}
                  style={{ width: '100%', padding: '0.5rem' }}
                />
                {errors.lastName && <span style={{ color: 'red' }}>{errors.lastName}</span>}
              </div>
              
              <div>
                <label>Birth Date *</label>
                <input
                  type="date"
                  value={formData.birthDate}
                  onChange={(e) => updateField('birthDate', e.target.value)}
                  style={{ width: '100%', padding: '0.5rem' }}
                />
                {errors.birthDate && <span style={{ color: 'red' }}>{errors.birthDate}</span>}
              </div>
            </div>
          </div>
        )}

        {currentStep === 'contact' && (
          <div>
            <h2>Contact Information</h2>
            <div style={{ display: 'grid', gap: '1rem' }}>
              <div>
                <label>Email *</label>
                <input
                  type="email"
                  value={formData.email}
                  onChange={(e) => updateField('email', e.target.value)}
                  style={{ width: '100%', padding: '0.5rem' }}
                />
                {errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
              </div>
              
              <div>
                <label>Phone *</label>
                <input
                  type="tel"
                  value={formData.phone}
                  onChange={(e) => updateField('phone', e.target.value)}
                  style={{ width: '100%', padding: '0.5rem' }}
                />
                {errors.phone && <span style={{ color: 'red' }}>{errors.phone}</span>}
              </div>
              
              <div>
                <label>Address *</label>
                <input
                  type="text"
                  value={formData.address}
                  onChange={(e) => updateField('address', e.target.value)}
                  style={{ width: '100%', padding: '0.5rem' }}
                />
                {errors.address && <span style={{ color: 'red' }}>{errors.address}</span>}
              </div>
            </div>
          </div>
        )}

        {currentStep === 'preferences' && (
          <div>
            <h2>Your Preferences</h2>
            <div style={{ display: 'grid', gap: '1rem' }}>
              <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
                <input
                  type="checkbox"
                  checked={formData.newsletter}
                  onChange={(e) => updateField('newsletter', e.target.checked)}
                />
                Subscribe to newsletter
              </label>
              
              <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
                <input
                  type="checkbox"
                  checked={formData.notifications}
                  onChange={(e) => updateField('notifications', e.target.checked)}
                />
                Enable notifications
              </label>
              
              <div>
                <label>Theme</label>
                <select
                  value={formData.theme}
                  onChange={(e) => updateField('theme', e.target.value)}
                  style={{ width: '100%', padding: '0.5rem' }}
                >
                  <option value="light">Light</option>
                  <option value="dark">Dark</option>
                </select>
              </div>
            </div>
          </div>
        )}

        {currentStep === 'review' && (
          <div>
            <h2>Review Your Information</h2>
            <div style={{ display: 'grid', gap: '1rem' }}>
              <div>
                <h3>Personal Information</h3>
                <p><strong>Name:</strong> {formData.firstName} {formData.lastName}</p>
                <p><strong>Birth Date:</strong> {formData.birthDate}</p>
              </div>
              
              <div>
                <h3>Contact Information</h3>
                <p><strong>Email:</strong> {formData.email}</p>
                <p><strong>Phone:</strong> {formData.phone}</p>
                <p><strong>Address:</strong> {formData.address}</p>
              </div>
              
              <div>
                <h3>Preferences</h3>
                <p><strong>Newsletter:</strong> {formData.newsletter ? 'Yes' : 'No'}</p>
                <p><strong>Notifications:</strong> {formData.notifications ? 'Yes' : 'No'}</p>
                <p><strong>Theme:</strong> {formData.theme}</p>
              </div>
            </div>
          </div>
        )}
      </div>

      {/* Navigation buttons */}
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <button
          onClick={handleBack}
          disabled={currentStepIndex === 0}
          style={{
            padding: '0.75rem 1.5rem',
            background: currentStepIndex === 0 ? '#ddd' : '#6c757d',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: currentStepIndex === 0 ? 'not-allowed' : 'pointer'
          }}
        >
          Back
        </button>

        {currentStep === 'review' ? (
          <button
            onClick={handleSubmit}
            style={{
              padding: '0.75rem 1.5rem',
              background: '#48bb78',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer'
            }}
          >
            Submit
          </button>
        ) : (
          <button
            onClick={handleNext}
            style={{
              padding: '0.75rem 1.5rem',
              background: '#667eea',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer'
            }}
          >
            Next
          </button>
        )}
      </div>
    </div>
  );
}

πŸ’‘ Multi-Step Form Best Practices

  • Show progress - Visual indicator of current step
  • Allow navigation - Users should be able to go back
  • Validate per step - Don't let users proceed with errors
  • Save progress - Persist data in localStorage for long forms
  • Review step - Let users review before final submit
  • Clear labeling - Name each step clearly

Conditional Fields

Show or hide fields based on other field values:

function ConditionalFieldsForm() {
  const [employmentStatus, setEmploymentStatus] = useState<'employed' | 'unemployed' | 'student'>('employed');
  const [companyName, setCompanyName] = useState('');
  const [schoolName, setSchoolName] = useState('');

  return (
    <form>
      <div>
        <label>Employment Status</label>
        <select
          value={employmentStatus}
          onChange={(e) => setEmploymentStatus(e.target.value as any)}
          style={{ width: '100%', padding: '0.5rem' }}
        >
          <option value="employed">Employed</option>
          <option value="unemployed">Unemployed</option>
          <option value="student">Student</option>
        </select>
      </div>

      {/* Show company field only if employed */}
      {employmentStatus === 'employed' && (
        <div style={{ marginTop: '1rem' }}>
          <label>Company Name *</label>
          <input
            type="text"
            value={companyName}
            onChange={(e) => setCompanyName(e.target.value)}
            style={{ width: '100%', padding: '0.5rem' }}
          />
        </div>
      )}

      {/* Show school field only if student */}
      {employmentStatus === 'student' && (
        <div style={{ marginTop: '1rem' }}>
          <label>School Name *</label>
          <input
            type="text"
            value={schoolName}
            onChange={(e) => setSchoolName(e.target.value)}
            style={{ width: '100%', padding: '0.5rem' }}
          />
        </div>
      )}

      <button type="submit" style={{ marginTop: '1rem' }}>
        Submit
      </button>
    </form>
  );
}

Dependent Validation

Validation rules that depend on multiple fields:

interface DateRangeForm {
  startDate: string;
  endDate: string;
  type: 'sameDay' | 'dateRange';
}

function DependentValidationForm() {
  const [formData, setFormData] = useState<DateRangeForm>({
    startDate: '',
    endDate: '',
    type: 'dateRange'
  });

  const [errors, setErrors] = useState<Partial<Record<keyof DateRangeForm, string>>>({});

  const validate = (): boolean => {
    const newErrors: Partial<Record<keyof DateRangeForm, string>> = {};

    if (!formData.startDate) {
      newErrors.startDate = 'Start date is required';
    }

    if (formData.type === 'dateRange') {
      if (!formData.endDate) {
        newErrors.endDate = 'End date is required for date range';
      } else if (formData.startDate && formData.endDate < formData.startDate) {
        newErrors.endDate = 'End date must be after start date';
      }
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (validate()) {
      console.log('Valid form data:', formData);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Type</label>
        <select
          value={formData.type}
          onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value as any }))}
          style={{ width: '100%', padding: '0.5rem' }}
        >
          <option value="sameDay">Same Day</option>
          <option value="dateRange">Date Range</option>
        </select>
      </div>

      <div style={{ marginTop: '1rem' }}>
        <label>Start Date *</label>
        <input
          type="date"
          value={formData.startDate}
          onChange={(e) => setFormData(prev => ({ ...prev, startDate: e.target.value }))}
          style={{ width: '100%', padding: '0.5rem' }}
        />
        {errors.startDate && <span style={{ color: 'red' }}>{errors.startDate}</span>}
      </div>

      {formData.type === 'dateRange' && (
        <div style={{ marginTop: '1rem' }}>
          <label>End Date *</label>
          <input
            type="date"
            value={formData.endDate}
            onChange={(e) => setFormData(prev => ({ ...prev, endDate: e.target.value }))}
            style={{ width: '100%', padding: '0.5rem' }}
          />
          {errors.endDate && <span style={{ color: 'red' }}>{errors.endDate}</span>}
        </div>
      )}

      <button type="submit" style={{ marginTop: '1rem' }}>
        Submit
      </button>
    </form>
  );
}

Form State Persistence

Save form progress to localStorage so users don't lose their work:

function useFormPersistence<T>(key: string, initialValue: T) {
  // Load from localStorage on mount
  const [value, setValue] = useState<T>(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // Save to localStorage whenever value changes
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('Failed to save to localStorage:', error);
    }
  }, [key, value]);

  // Clear localStorage
  const clearStorage = () => {
    localStorage.removeItem(key);
    setValue(initialValue);
  };

  return [value, setValue, clearStorage] as const;
}

function PersistentForm() {
  const [formData, setFormData, clearFormData] = useFormPersistence('job-application', {
    name: '',
    email: '',
    resume: ''
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log('Submitting:', formData);
    // Clear saved data after successful submission
    clearFormData();
  };

  return (
    <form onSubmit={handleSubmit}>
      <div style={{ marginBottom: '1rem', padding: '1rem', background: '#e3f2fd' }}>
        πŸ’Ύ Your progress is automatically saved
      </div>

      <input
        type="text"
        value={formData.name}
        onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
        placeholder="Name"
        style={{ width: '100%', padding: '0.5rem', marginBottom: '1rem' }}
      />

      <input
        type="email"
        value={formData.email}
        onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
        placeholder="Email"
        style={{ width: '100%', padding: '0.5rem', marginBottom: '1rem' }}
      />

      <textarea
        value={formData.resume}
        onChange={(e) => setFormData(prev => ({ ...prev, resume: e.target.value }))}
        placeholder="Resume/Cover Letter"
        rows={5}
        style={{ width: '100%', padding: '0.5rem', marginBottom: '1rem' }}
      />

      <div style={{ display: 'flex', gap: '1rem' }}>
        <button type="submit">Submit</button>
        <button type="button" onClick={clearFormData}>Clear Form</button>
      </div>
    </form>
  );
}

βœ… When to Persist Form Data

  • Long forms (10+ fields)
  • Multi-step wizards
  • Forms with file uploads or heavy content
  • Job applications, surveys, registrations

Remember: Clear localStorage after successful submission and don't store sensitive data!

🎯 Complete Example: Job Application Form

Let's put everything together in a comprehensive job application form that demonstrates all the patterns we've learned:

πŸ“‹ Features Demonstrated

  • βœ… Object state management
  • βœ… Multiple validation strategies
  • βœ… Clear error messages
  • βœ… Dynamic field arrays (work experience)
  • βœ… Conditional fields
  • βœ… File handling
  • βœ… TypeScript typing
  • βœ… Accessible form controls
interface WorkExperience {
  id: string;
  company: string;
  position: string;
  startDate: string;
  endDate: string;
  current: boolean;
}

interface JobApplicationData {
  // Personal
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  
  // Professional
  yearsExperience: string;
  workExperiences: WorkExperience[];
  
  // Documents
  resume: File | null;
  coverLetter: string;
  
  // Optional
  linkedIn: string;
  portfolio: string;
  referral: boolean;
  referralSource: string;
}

function JobApplicationForm() {
  const [formData, setFormData] = useState<JobApplicationData>({
    firstName: '',
    lastName: '',
    email: '',
    phone: '',
    yearsExperience: '',
    workExperiences: [{
      id: crypto.randomUUID(),
      company: '',
      position: '',
      startDate: '',
      endDate: '',
      current: false
    }],
    resume: null,
    coverLetter: '',
    linkedIn: '',
    portfolio: '',
    referral: false,
    referralSource: ''
  });

  const [errors, setErrors] = useState<Record<string, string>>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Validation functions
  const validateEmail = (email: string): string | undefined => {
    if (!email) return 'Email is required';
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return 'Please enter a valid email address';
    }
  };

  const validatePhone = (phone: string): string | undefined => {
    if (!phone) return 'Phone number is required';
    if (!/^\d{10}$/.test(phone.replace(/\D/g, ''))) {
      return 'Please enter a valid 10-digit phone number';
    }
  };

  const validateRequired = (value: string, fieldName: string): string | undefined => {
    if (!value.trim()) return `${fieldName} is required`;
  };

  // Handle field updates
  const updateField = (field: keyof JobApplicationData, value: any) => {
    setFormData(prev => ({ ...prev, [field]: value }));
    
    // Clear error when user types
    if (errors[field]) {
      setErrors(prev => {
        const newErrors = { ...prev };
        delete newErrors[field];
        return newErrors;
      });
    }
  };

  // Handle blur
  const handleBlur = (field: string) => {
    setTouched(prev => ({ ...prev, [field]: true }));
    
    // Run validation for this field
    let error: string | undefined;
    switch (field) {
      case 'firstName':
        error = validateRequired(formData.firstName, 'First name');
        break;
      case 'lastName':
        error = validateRequired(formData.lastName, 'Last name');
        break;
      case 'email':
        error = validateEmail(formData.email);
        break;
      case 'phone':
        error = validatePhone(formData.phone);
        break;
      case 'yearsExperience':
        error = validateRequired(formData.yearsExperience, 'Years of experience');
        break;
    }
    
    if (error) {
      setErrors(prev => ({ ...prev, [field]: error }));
    }
  };

  // Work experience handlers
  const addExperience = () => {
    setFormData(prev => ({
      ...prev,
      workExperiences: [
        ...prev.workExperiences,
        {
          id: crypto.randomUUID(),
          company: '',
          position: '',
          startDate: '',
          endDate: '',
          current: false
        }
      ]
    }));
  };

  const removeExperience = (id: string) => {
    setFormData(prev => ({
      ...prev,
      workExperiences: prev.workExperiences.filter(exp => exp.id !== id)
    }));
  };

  const updateExperience = (id: string, field: keyof WorkExperience, value: any) => {
    setFormData(prev => ({
      ...prev,
      workExperiences: prev.workExperiences.map(exp =>
        exp.id === id ? { ...exp, [field]: value } : exp
      )
    }));
  };

  // File handling
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0] || null;
    
    if (file) {
      // Validate file
      if (file.size > 5 * 1024 * 1024) { // 5MB
        setErrors(prev => ({ ...prev, resume: 'File size must be less than 5MB' }));
        return;
      }
      
      if (!['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'].includes(file.type)) {
        setErrors(prev => ({ ...prev, resume: 'Only PDF and Word documents are allowed' }));
        return;
      }
    }
    
    updateField('resume', file);
  };

  // Validate entire form
  const validateForm = (): boolean => {
    const newErrors: Record<string, string> = {};

    // Basic fields
    const firstNameError = validateRequired(formData.firstName, 'First name');
    if (firstNameError) newErrors.firstName = firstNameError;

    const lastNameError = validateRequired(formData.lastName, 'Last name');
    if (lastNameError) newErrors.lastName = lastNameError;

    const emailError = validateEmail(formData.email);
    if (emailError) newErrors.email = emailError;

    const phoneError = validatePhone(formData.phone);
    if (phoneError) newErrors.phone = phoneError;

    const expError = validateRequired(formData.yearsExperience, 'Years of experience');
    if (expError) newErrors.yearsExperience = expError;

    // Resume
    if (!formData.resume) {
      newErrors.resume = 'Resume is required';
    }

    // Work experiences
    formData.workExperiences.forEach((exp, index) => {
      if (!exp.company) newErrors[`exp-${index}-company`] = 'Company is required';
      if (!exp.position) newErrors[`exp-${index}-position`] = 'Position is required';
    });

    // Conditional validation
    if (formData.referral && !formData.referralSource) {
      newErrors.referralSource = 'Please tell us who referred you';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  // Submit
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    // Mark all as touched
    setTouched({
      firstName: true,
      lastName: true,
      email: true,
      phone: true,
      yearsExperience: true
    });

    if (!validateForm()) {
      alert('Please fix the errors before submitting');
      return;
    }

    setIsSubmitting(true);

    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 2000));
      
      console.log('Application submitted:', formData);
      alert('Application submitted successfully!');
      
      // Reset form
      setFormData({
        firstName: '',
        lastName: '',
        email: '',
        phone: '',
        yearsExperience: '',
        workExperiences: [{
          id: crypto.randomUUID(),
          company: '',
          position: '',
          startDate: '',
          endDate: '',
          current: false
        }],
        resume: null,
        coverLetter: '',
        linkedIn: '',
        portfolio: '',
        referral: false,
        referralSource: ''
      });
      setErrors({});
      setTouched({});
      
    } catch (error) {
      setErrors({ submit: 'Failed to submit application. Please try again.' });
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '800px', margin: '0 auto' }}>
      <h2>Job Application</h2>

      {/* Personal Information Section */}
      <section style={{ marginBottom: '2rem' }}>
        <h3>Personal Information</h3>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
          <div>
            <label htmlFor="firstName">First Name *</label>
            <input
              id="firstName"
              type="text"
              value={formData.firstName}
              onChange={(e) => updateField('firstName', e.target.value)}
              onBlur={() => handleBlur('firstName')}
              style={{
                width: '100%',
                padding: '0.5rem',
                border: `1px solid ${errors.firstName && touched.firstName ? '#f56565' : '#ddd'}`
              }}
            />
            {errors.firstName && touched.firstName && (
              <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                {errors.firstName}
              </span>
            )}
          </div>

          <div>
            <label htmlFor="lastName">Last Name *</label>
            <input
              id="lastName"
              type="text"
              value={formData.lastName}
              onChange={(e) => updateField('lastName', e.target.value)}
              onBlur={() => handleBlur('lastName')}
              style={{
                width: '100%',
                padding: '0.5rem',
                border: `1px solid ${errors.lastName && touched.lastName ? '#f56565' : '#ddd'}`
              }}
            />
            {errors.lastName && touched.lastName && (
              <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                {errors.lastName}
              </span>
            )}
          </div>
        </div>

        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}>
          <div>
            <label htmlFor="email">Email *</label>
            <input
              id="email"
              type="email"
              value={formData.email}
              onChange={(e) => updateField('email', e.target.value)}
              onBlur={() => handleBlur('email')}
              style={{
                width: '100%',
                padding: '0.5rem',
                border: `1px solid ${errors.email && touched.email ? '#f56565' : '#ddd'}`
              }}
            />
            {errors.email && touched.email && (
              <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                {errors.email}
              </span>
            )}
          </div>

          <div>
            <label htmlFor="phone">Phone *</label>
            <input
              id="phone"
              type="tel"
              value={formData.phone}
              onChange={(e) => updateField('phone', e.target.value)}
              onBlur={() => handleBlur('phone')}
              placeholder="(555) 123-4567"
              style={{
                width: '100%',
                padding: '0.5rem',
                border: `1px solid ${errors.phone && touched.phone ? '#f56565' : '#ddd'}`
              }}
            />
            {errors.phone && touched.phone && (
              <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                {errors.phone}
              </span>
            )}
          </div>
        </div>
      </section>

      {/* Professional Experience */}
      <section style={{ marginBottom: '2rem' }}>
        <h3>Professional Experience</h3>
        
        <div style={{ marginBottom: '1rem' }}>
          <label htmlFor="yearsExperience">Years of Experience *</label>
          <select
            id="yearsExperience"
            value={formData.yearsExperience}
            onChange={(e) => updateField('yearsExperience', e.target.value)}
            onBlur={() => handleBlur('yearsExperience')}
            style={{
              width: '100%',
              padding: '0.5rem',
              border: `1px solid ${errors.yearsExperience && touched.yearsExperience ? '#f56565' : '#ddd'}`
            }}
          >
            <option value="">Select...</option>
            <option value="0-1">0-1 years</option>
            <option value="1-3">1-3 years</option>
            <option value="3-5">3-5 years</option>
            <option value="5-10">5-10 years</option>
            <option value="10+">10+ years</option>
          </select>
          {errors.yearsExperience && touched.yearsExperience && (
            <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
              {errors.yearsExperience}
            </span>
          )}
        </div>

        {/* Work Experiences Array */}
        <h4>Work History</h4>
        {formData.workExperiences.map((exp, index) => (
          <div
            key={exp.id}
            style={{
              border: '1px solid #ddd',
              padding: '1rem',
              marginBottom: '1rem',
              borderRadius: '4px'
            }}
          >
            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
              <strong>Experience {index + 1}</strong>
              {formData.workExperiences.length > 1 && (
                <button
                  type="button"
                  onClick={() => removeExperience(exp.id)}
                  style={{
                    background: '#f56565',
                    color: 'white',
                    border: 'none',
                    padding: '0.25rem 0.5rem',
                    borderRadius: '4px',
                    cursor: 'pointer'
                  }}
                >
                  Remove
                </button>
              )}
            </div>

            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
              <input
                type="text"
                value={exp.company}
                onChange={(e) => updateExperience(exp.id, 'company', e.target.value)}
                placeholder="Company"
                style={{ padding: '0.5rem' }}
              />
              <input
                type="text"
                value={exp.position}
                onChange={(e) => updateExperience(exp.id, 'position', e.target.value)}
                placeholder="Position"
                style={{ padding: '0.5rem' }}
              />
            </div>
          </div>
        ))}

        <button
          type="button"
          onClick={addExperience}
          style={{
            background: '#48bb78',
            color: 'white',
            border: 'none',
            padding: '0.5rem 1rem',
            borderRadius: '4px',
            cursor: 'pointer'
          }}
        >
          + Add Work Experience
        </button>
      </section>

      {/* Documents */}
      <section style={{ marginBottom: '2rem' }}>
        <h3>Documents</h3>
        
        <div style={{ marginBottom: '1rem' }}>
          <label htmlFor="resume">Resume *</label>
          <input
            id="resume"
            type="file"
            onChange={handleFileChange}
            accept=".pdf,.doc,.docx"
            style={{ display: 'block', marginTop: '0.5rem' }}
          />
          {formData.resume && (
            <p style={{ fontSize: '0.875rem', color: '#48bb78' }}>
              βœ“ {formData.resume.name}
            </p>
          )}
          {errors.resume && (
            <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
              {errors.resume}
            </span>
          )}
        </div>

        <div>
          <label htmlFor="coverLetter">Cover Letter (Optional)</label>
          <textarea
            id="coverLetter"
            value={formData.coverLetter}
            onChange={(e) => updateField('coverLetter', e.target.value)}
            placeholder="Tell us why you're interested in this position..."
            rows={5}
            style={{
              width: '100%',
              padding: '0.5rem',
              marginTop: '0.5rem',
              fontFamily: 'inherit'
            }}
          />
        </div>
      </section>

      {/* Referral - Conditional Field */}
      <section style={{ marginBottom: '2rem' }}>
        <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
          <input
            type="checkbox"
            checked={formData.referral}
            onChange={(e) => updateField('referral', e.target.checked)}
          />
          I was referred by someone
        </label>

        {formData.referral && (
          <div style={{ marginTop: '1rem' }}>
            <label htmlFor="referralSource">Who referred you? *</label>
            <input
              id="referralSource"
              type="text"
              value={formData.referralSource}
              onChange={(e) => updateField('referralSource', e.target.value)}
              placeholder="Name of person who referred you"
              style={{
                width: '100%',
                padding: '0.5rem',
                marginTop: '0.5rem',
                border: `1px solid ${errors.referralSource ? '#f56565' : '#ddd'}`
              }}
            />
            {errors.referralSource && (
              <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                {errors.referralSource}
              </span>
            )}
          </div>
        )}
      </section>

      {/* Submit Error */}
      {errors.submit && (
        <div
          style={{
            background: '#ffebee',
            border: '1px solid #f56565',
            padding: '1rem',
            marginBottom: '1rem',
            borderRadius: '4px',
            color: '#f56565'
          }}
        >
          {errors.submit}
        </div>
      )}

      {/* Submit Button */}
      <button
        type="submit"
        disabled={isSubmitting}
        style={{
          width: '100%',
          padding: '1rem',
          background: isSubmitting ? '#ddd' : '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          fontSize: '1.1rem',
          fontWeight: 'bold',
          cursor: isSubmitting ? 'not-allowed' : 'pointer'
        }}
      >
        {isSubmitting ? 'Submitting...' : 'Submit Application'}
      </button>
    </form>
  );
}

βœ… What This Example Demonstrates

  • Structured state management with proper TypeScript typing
  • Comprehensive validation with clear error messages
  • Dynamic field arrays (work experiences)
  • File upload with validation
  • Conditional fields (referral source)
  • Loading states during submission
  • Proper form reset after submission
  • Accessible form controls with labels and IDs
  • Clean, maintainable code structure

⭐ Best Practices

Let's wrap up with a comprehensive list of best practices for complex form handling:

State Management

  • βœ… Use object state for forms with 5+ fields
  • βœ… Consider useReducer for very complex forms (20+ fields)
  • βœ… Keep related data together (don't split unnecessarily)
  • βœ… Use proper TypeScript interfaces for form data
  • βœ… Separate form data from UI state (errors, touched, isSubmitting)

Validation

  • βœ… Use on-blur validation for most fields
  • βœ… Use real-time validation for password strength and formatting
  • βœ… Debounce expensive validations (async checks)
  • βœ… Validate everything on submit as final check
  • βœ… Clear errors when user starts fixing them
  • βœ… Track touched state to avoid showing errors prematurely

Error Messages

  • βœ… Be specific and helpful ("Email must include @" not "Invalid")
  • βœ… Position errors near the relevant field
  • βœ… Use color AND text (don't rely on color alone)
  • βœ… Make errors accessible with ARIA attributes
  • βœ… Show one error per field to avoid overwhelming users
  • βœ… Use friendly, conversational language

Performance

  • βœ… Split large forms into smaller components
  • βœ… Use React.memo for field components
  • βœ… Use useCallback for event handlers
  • βœ… Debounce expensive operations
  • βœ… Consider uncontrolled inputs for simple forms
  • βœ… Use proper keys for field arrays (ID, not index)

Accessibility

  • βœ… Associate labels with inputs using htmlFor/id
  • βœ… Use aria-invalid on fields with errors
  • βœ… Use aria-describedby to link errors to inputs
  • βœ… Use role="alert" on error messages
  • βœ… Mark required fields with aria-required
  • βœ… Ensure sufficient color contrast (4.5:1)
  • βœ… Make forms keyboard navigable

User Experience

  • βœ… Show clear progress for multi-step forms
  • βœ… Allow users to save and resume long forms
  • βœ… Provide helpful placeholder text and examples
  • βœ… Disable submit button during submission
  • βœ… Show loading indicators for async operations
  • βœ… Confirm successful submissions clearly
  • βœ… Allow users to go back in multi-step forms

Security & Privacy

  • βœ… Validate on both client and server
  • βœ… Don't store sensitive data in localStorage
  • βœ… Use proper input types (email, tel, password)
  • βœ… Implement rate limiting for async validations
  • βœ… Clear form data after successful submission
  • βœ… Use HTTPS for all form submissions

πŸ“š Summary

Congratulations! You've mastered complex form handling in React with TypeScript. Let's recap what you've learned:

Key Takeaways

  • βœ… State Management - Use object state for scalability, useReducer for very complex forms
  • βœ… Validation - Combine multiple strategies: real-time, on-blur, on-submit, and debounced async
  • βœ… Error Messages - Be specific, helpful, visible, and accessible
  • βœ… Dynamic Fields - Use arrays with unique IDs, never array indices as keys
  • βœ… Performance - Split into components, use memo and useCallback, debounce expensive ops
  • βœ… Advanced Patterns - Multi-step forms, conditional fields, dependent validation, persistence
  • βœ… TypeScript - Properly type all form data, errors, and handlers
  • βœ… Accessibility - Use proper ARIA attributes, labels, and keyboard navigation

🎯 What's Next?

In the upcoming lessons, you'll learn:

  • Lesson 7.2: React Hook Form - A powerful library that makes forms much easier
  • Lesson 7.3: Form Validation with Zod - Schema-based validation for type-safe forms
  • Lesson 7.4: File Uploads - Handling images, documents, and multiple files
  • Lesson 7.5: Advanced Form Patterns - Form wizards, dynamic schemas, and more

These lessons will build on the foundation you've established here!

πŸ’ͺ You've Learned

  • How to manage complex form state effectively
  • Multiple validation strategies and when to use each
  • How to display clear, helpful error messages
  • Dynamic field arrays with add/remove/update functionality
  • Form performance optimization techniques
  • Advanced patterns like multi-step forms and conditional fields
  • How to build production-ready, accessible forms

You're now equipped to build professional-grade forms in React!