Skip to main content

๐ŸŽจ Lesson 7.5: Advanced Form Patterns

You've mastered the fundamentals of forms, validation, and file uploads. Now it's time to level up with advanced patterns that you'll encounter in real-world applications. Think about complex forms you've filled out onlineโ€”multi-step checkout processes, conditional questions that appear based on your answers, forms that save your progress automatically, or wizards that guide you through complex data entry. These aren't just nice-to-have features; they're essential for creating professional, user-friendly applications. In this lesson, you'll learn to build all of these patterns with React, TypeScript, and React Hook Form.

๐ŸŽฏ Learning Objectives

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

  • Build multi-step forms with proper state management and navigation
  • Implement conditional fields that show/hide based on user input
  • Create form wizards with progress tracking and validation per step
  • Implement auto-save functionality with debouncing
  • Handle field dependencies and computed values
  • Make forms accessible with proper ARIA attributes and keyboard navigation
  • Persist form data to localStorage for better UX
  • Implement review/summary steps before submission
  • Handle complex form navigation patterns (back, next, skip, cancel)

Estimated Time: 75-90 minutes

Project: Build a complete multi-step checkout form with conditional fields and auto-save

๐Ÿ“‘ In This Lesson

๐ŸŒŸ Introduction to Advanced Form Patterns

Forms are the primary way users interact with web applications, and as applications grow more complex, so do their forms. Let's look at some real-world scenarios where advanced form patterns are essential:

Common Advanced Form Scenarios

  • E-commerce Checkout: Multi-step process (shipping โ†’ payment โ†’ review โ†’ confirmation)
  • User Onboarding: Wizard-style forms that guide new users through setup
  • Insurance Applications: Conditional questions based on coverage type, age, health status
  • Job Applications: Different fields for different job types, auto-save drafts
  • Tax Filing Software: Complex conditional logic, field dependencies, calculations
  • Survey Forms: Skip logic, branching questions, progress tracking
  • Account Settings: Auto-save changes, show/hide sections, field validation

๐Ÿ“– What Makes a Form "Advanced"?

Advanced Form Patterns: Techniques that go beyond basic single-page forms with simple validation. These include multi-step navigation, conditional field visibility, field dependencies, auto-save functionality, complex validation rules, and sophisticated user guidance patterns like wizards. They're "advanced" because they require careful state management, user experience consideration, and often integration with multiple systems.

Why These Patterns Matter

Pattern User Benefit Business Benefit
Multi-Step Forms Less overwhelming, focused attention Higher completion rates
Conditional Fields Only see relevant questions Cleaner data, faster completion
Auto-Save No lost work, peace of mind Reduced abandonment
Progress Indicators Know how far they've come Increased completion motivation
Field Dependencies Intelligent defaults, less typing Better data quality
Form Wizards Guided experience, clear path Consistent onboarding

โœ… The Psychology of Form Design

Research shows that:

  • Multi-step forms can increase conversions by up to 300% compared to long single-page forms
  • Progress bars increase completion rates by giving users a sense of accomplishment
  • Auto-save reduces anxiety and makes users more likely to complete longer forms
  • Conditional fields reduce cognitive load by showing only relevant questions

Good form design isn't just about codeโ€”it's about understanding human behavior!

What We'll Build Together

Throughout this lesson, we'll progressively build a complete multi-step checkout form that demonstrates all these patterns:

graph LR A[Account Info] --> B[Shipping Address] B --> C{Shipping = Billing?} C -->|No| D[Billing Address] C -->|Yes| E[Payment Method] D --> E E --> F[Review & Submit] F --> G[Confirmation] style A fill:#667eea,stroke:#333,stroke-width:2px,color:#fff style G fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style C fill:#FFA726,stroke:#333,stroke-width:2px,color:#fff

This form will include:

  • โœ… Multiple steps with navigation
  • โœ… Conditional billing address step
  • โœ… Field dependencies (state โ†’ cities)
  • โœ… Auto-save to localStorage
  • โœ… Per-step validation
  • โœ… Progress indicator
  • โœ… Review/summary step
  • โœ… Full accessibility support

๐Ÿ’ก Tools We'll Use

We'll leverage everything you've learned so far:

  • React Hook Form: For efficient form state management
  • Zod: For schema validation
  • TypeScript: For type safety across complex form state
  • localStorage: For persisting form data
  • Custom Hooks: For reusable form logic

๐Ÿ“Š Multi-Step Forms: The Basics

Multi-step forms break a long form into smaller, more manageable pieces. Instead of overwhelming users with 50 fields at once, you present them with 5-10 fields per step. This improves both the user experience and completion rates.

Key Concepts

1. State Management

In a multi-step form, you need to track:

  • Current Step: Which step the user is on (e.g., step 1 of 4)
  • Form Data: All data from all steps, even those not currently visible
  • Validation State: Which steps have been completed/validated
  • Step History: Where the user has been (for back button)
interface MultiStepFormState {
  currentStep: number;
  completedSteps: Set<number>;
  formData: {
    step1: Step1Data;
    step2: Step2Data;
    step3: Step3Data;
  };
}

2. Step Components

Each step is typically its own component with its own validation schema:

// Step 1: Account Information
const AccountInfoStep = ({ onNext, defaultValues }) => {
  // Form logic for this step only
  // Validate and call onNext with data
};

// Step 2: Shipping Address
const ShippingStep = ({ onNext, onBack, defaultValues }) => {
  // Form logic for shipping
};

// And so on...

3. Navigation Patterns

Action Behavior Validation
Next Move to next step Validate current step before proceeding
Back Return to previous step No validation required (data preserved)
Skip Jump to next step (if allowed) Optional step, no validation
Jump to Step Navigate to specific step Only allow if step is completed or next in sequence
Submit Final submission (last step) Validate all steps, then submit

โš ๏ธ Common Pitfalls

  • Losing Data: When navigating between steps, preserve all form data
  • Over-Validation: Don't validate steps the user hasn't reached yet
  • Unclear Progress: Always show where users are in the process
  • Disabled Back Button: Users should always be able to go back and change answers
  • Too Many Steps: More than 5-6 steps feels tedious. Group related fields.

Architectural Approaches

There are two main ways to structure multi-step forms:

Approach 1: Single Form with Conditional Rendering

const MultiStepForm = () => {
  const [step, setStep] = useState(1);
  const methods = useForm(); // Single form instance

  return (
    <FormProvider {...methods}>
      {step === 1 && <Step1 />}
      {step === 2 && <Step2 />}
      {step === 3 && <Step3 />}
    </FormProvider>
  );
};

Pros: All data in one form, simple state management
Cons: All validation schemas load upfront, harder to code-split

Approach 2: Separate Forms with Shared State

const MultiStepForm = () => {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({});

  const handleStepComplete = (stepData) => {
    setFormData(prev => ({ ...prev, ...stepData }));
    setStep(prev => prev + 1);
  };

  return (
    <>
      {step === 1 && <Step1Form onComplete={handleStepComplete} />}
      {step === 2 && <Step2Form onComplete={handleStepComplete} />}
      {step === 3 && <Step3Form onComplete={handleStepComplete} />}
    </>
  );
};

Pros: Better code splitting, isolated validation
Cons: More complex state management, need to pass data between steps

โœ… Recommendation

For most applications, Approach 2 (separate forms with shared state) is better because:

  • Better performance (lazy load steps)
  • Easier to test individual steps
  • More flexible validation rules per step
  • Cleaner separation of concerns

We'll use this approach in our examples.

Data Flow in Multi-Step Forms

sequenceDiagram participant User participant Step1 participant State participant Step2 participant API User->>Step1: Fill form fields User->>Step1: Click "Next" Step1->>Step1: Validate data Step1->>State: Save step1 data State->>Step2: Provide default values Step2->>User: Show step 2 User->>Step2: Fill form fields User->>Step2: Click "Back" Step2->>State: Save step2 data (draft) State->>Step1: Restore with saved data Step1->>User: Show step 1 User->>Step1: Click "Next" again Step1->>State: Confirm step1 data State->>Step2: Show step 2 with saved data User->>Step2: Click "Submit" Step2->>Step2: Validate final data Step2->>State: Combine all data State->>API: Submit complete form API->>User: Show success

๐Ÿ’ก Key Principle: Progressive Enhancement

Think of multi-step forms as progressive enhancement:

  • Step 1: Get the minimum required information
  • Step 2: Enhance with additional details
  • Step 3: Optional customization
  • Final Step: Review and confirm

Each step should feel like a natural progression, not an arbitrary division.

๐Ÿ”จ Building Your First Multi-Step Form

Let's build a practical multi-step form from scratch. We'll create a user registration form with three steps: Account Info, Personal Details, and Preferences.

Step 1: Define Types and Schemas

First, let's define our TypeScript types and Zod schemas for each step:

import { z } from 'zod';

// Step 1: Account Information
const accountInfoSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain uppercase letter')
    .regex(/[0-9]/, 'Password must contain number'),
  confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword']
});

type AccountInfo = z.infer<typeof accountInfoSchema>;

// Step 2: Personal Details
const personalDetailsSchema = z.object({
  firstName: z.string().min(2, 'First name is required'),
  lastName: z.string().min(2, 'Last name is required'),
  dateOfBirth: z.string().regex(
    /^\d{4}-\d{2}-\d{2}$/,
    'Date must be in YYYY-MM-DD format'
  ),
  phone: z.string().regex(
    /^\d{10}$/,
    'Phone must be 10 digits'
  ).optional()
});

type PersonalDetails = z.infer<typeof personalDetailsSchema>;

// Step 3: Preferences
const preferencesSchema = z.object({
  newsletter: z.boolean(),
  notifications: z.enum(['all', 'important', 'none']),
  interests: z.array(z.string()).min(1, 'Select at least one interest')
});

type Preferences = z.infer<typeof preferencesSchema>;

// Combined form data
interface RegistrationFormData {
  accountInfo?: AccountInfo;
  personalDetails?: PersonalDetails;
  preferences?: Preferences;
}

Step 2: Create the Main Form Container

import React, { useState } from 'react';

const RegistrationForm: React.FC = () => {
  const [currentStep, setCurrentStep] = useState(1);
  const [formData, setFormData] = useState<RegistrationFormData>({});

  const totalSteps = 3;

  const handleStepComplete = (stepName: keyof RegistrationFormData, data: any) => {
    // Save step data
    setFormData(prev => ({
      ...prev,
      [stepName]: data
    }));

    // Move to next step
    if (currentStep < totalSteps) {
      setCurrentStep(prev => prev + 1);
    }
  };

  const handleBack = () => {
    if (currentStep > 1) {
      setCurrentStep(prev => prev - 1);
    }
  };

  const handleSubmit = async () => {
    console.log('Submitting complete form:', formData);
    
    try {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData)
      });

      if (response.ok) {
        alert('Registration successful!');
        // Redirect to login or dashboard
      }
    } catch (error) {
      console.error('Registration failed:', error);
      alert('Registration failed. Please try again.');
    }
  };

  return (
    <div className="registration-form">
      <h1>Create Your Account</h1>
      
      {/* Progress indicator */}
      <div className="progress-steps">
        {[1, 2, 3].map(step => (
          <div
            key={step}
            className={`step ${step === currentStep ? 'active' : ''} ${
              step < currentStep ? 'completed' : ''
            }`}
          >
            {step}
          </div>
        ))}
      </div>

      {/* Render current step */}
      {currentStep === 1 && (
        <AccountInfoStep
          onNext={(data) => handleStepComplete('accountInfo', data)}
          defaultValues={formData.accountInfo}
        />
      )}

      {currentStep === 2 && (
        <PersonalDetailsStep
          onNext={(data) => handleStepComplete('personalDetails', data)}
          onBack={handleBack}
          defaultValues={formData.personalDetails}
        />
      )}

      {currentStep === 3 && (
        <PreferencesStep
          onSubmit={(data) => {
            handleStepComplete('preferences', data);
            handleSubmit();
          }}
          onBack={handleBack}
          defaultValues={formData.preferences}
        />
      )}
    </div>
  );
};

export default RegistrationForm;

Step 3: Create Individual Step Components

Now let's create each step component. Here's the first step:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

interface AccountInfoStepProps {
  onNext: (data: AccountInfo) => void;
  defaultValues?: AccountInfo;
}

const AccountInfoStep: React.FC<AccountInfoStepProps> = ({
  onNext,
  defaultValues
}) => {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<AccountInfo>({
    resolver: zodResolver(accountInfoSchema),
    defaultValues
  });

  const onSubmit = (data: AccountInfo) => {
    onNext(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <h2>Step 1: Account Information</h2>

      <div className="form-group">
        <label htmlFor="email">Email Address *</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" className="error" role="alert">
            {errors.email.message}
          </span>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="password">Password *</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          aria-invalid={errors.password ? 'true' : 'false'}
          aria-describedby={errors.password ? 'password-error' : undefined}
        />
        {errors.password && (
          <span id="password-error" className="error" role="alert">
            {errors.password.message}
          </span>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="confirmPassword">Confirm Password *</label>
        <input
          id="confirmPassword"
          type="password"
          {...register('confirmPassword')}
          aria-invalid={errors.confirmPassword ? 'true' : 'false'}
          aria-describedby={
            errors.confirmPassword ? 'confirm-password-error' : undefined
          }
        />
        {errors.confirmPassword && (
          <span id="confirm-password-error" className="error" role="alert">
            {errors.confirmPassword.message}
          </span>
        )}
      </div>

      <div className="form-actions">
        <button type="submit" className="btn-primary">
          Next โ†’
        </button>
      </div>
    </form>
  );
};

โœ… Key Patterns in This Implementation

  • Default Values: Each step receives previous data via defaultValues
  • Independent Validation: Each step has its own schema and validates independently
  • Callback Pattern: Steps call onNext with validated data
  • Type Safety: TypeScript ensures data structure is correct across steps
  • Accessibility: Proper ARIA attributes for errors and form controls

Step 4: Second and Third Step Components

The other steps follow the same pattern, but with onBack support:

interface PersonalDetailsStepProps {
  onNext: (data: PersonalDetails) => void;
  onBack: () => void;
  defaultValues?: PersonalDetails;
}

const PersonalDetailsStep: React.FC<PersonalDetailsStepProps> = ({
  onNext,
  onBack,
  defaultValues
}) => {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<PersonalDetails>({
    resolver: zodResolver(personalDetailsSchema),
    defaultValues
  });

  return (
    <form onSubmit={handleSubmit(onNext)}>
      <h2>Step 2: Personal Details</h2>

      <div className="form-group">
        <label htmlFor="firstName">First Name *</label>
        <input
          id="firstName"
          {...register('firstName')}
          aria-invalid={errors.firstName ? 'true' : 'false'}
        />
        {errors.firstName && (
          <span className="error" role="alert">
            {errors.firstName.message}
          </span>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="lastName">Last Name *</label>
        <input
          id="lastName"
          {...register('lastName')}
          aria-invalid={errors.lastName ? 'true' : 'false'}
        />
        {errors.lastName && (
          <span className="error" role="alert">
            {errors.lastName.message}
          </span>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="dateOfBirth">Date of Birth *</label>
        <input
          id="dateOfBirth"
          type="date"
          {...register('dateOfBirth')}
          aria-invalid={errors.dateOfBirth ? 'true' : 'false'}
        />
        {errors.dateOfBirth && (
          <span className="error" role="alert">
            {errors.dateOfBirth.message}
          </span>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="phone">Phone (Optional)</label>
        <input
          id="phone"
          type="tel"
          {...register('phone')}
          placeholder="1234567890"
        />
        {errors.phone && (
          <span className="error" role="alert">
            {errors.phone.message}
          </span>
        )}
      </div>

      <div className="form-actions">
        <button type="button" onClick={onBack} className="btn-secondary">
          โ† Back
        </button>
        <button type="submit" className="btn-primary">
          Next โ†’
        </button>
      </div>
    </form>
  );
};

๐Ÿ’ก Notice the Pattern

Each step component is remarkably similar:

  1. Receives onNext, onBack (except first step), and defaultValues
  2. Uses useForm with its own schema and default values
  3. Renders form fields with error handling
  4. Provides navigation buttons (Back + Next or Submit)

This consistency makes the code easy to understand and maintain!

๐Ÿงญ Step Navigation and Validation

Navigation in multi-step forms needs to be intelligent. You can't just move between steps freelyโ€”you need to validate data, handle edge cases, and provide a smooth user experience. Let's explore advanced navigation patterns.

Validation Strategies

There are several approaches to validation in multi-step forms:

Strategy When to Validate Pros Cons
Validate on Next When clicking "Next" button Users can freely edit, no premature errors Errors only shown when trying to proceed
Validate on Blur When leaving each field Immediate feedback per field Can feel aggressive/annoying
Validate on Change As user types Real-time feedback Very aggressive, can be distracting
Hybrid Approach On Next initially, then on Change for errors Best UXโ€”forgiving at first, helpful after More complex to implement

โœ… Recommended: Hybrid Approach

The best user experience comes from:

  1. First attempt: Validate only when user clicks "Next"
  2. After errors: Show real-time validation for fields that had errors
  3. Going back: Don't validate when going back (preserve data as-is)

React Hook Form supports this pattern natively with its mode option!

Implementing Smart Validation

const SmartValidationStep: React.FC<StepProps> = ({
  onNext,
  onBack,
  defaultValues
}) => {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitted }
  } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues,
    mode: 'onSubmit', // Validate on first submit
    reValidateMode: 'onChange' // Then validate on change
  });

  return (
    <form onSubmit={handleSubmit(onNext)}>
      {/* Form fields */}
      
      <div className="form-actions">
        <button 
          type="button" 
          onClick={onBack}
          className="btn-secondary"
        >
          โ† Back
        </button>
        <button type="submit" className="btn-primary">
          Next โ†’
        </button>
      </div>
    </form>
  );
};

Preventing Navigation with Unsaved Changes

When users try to navigate away or close the browser with unsaved changes, warn them:

import { useEffect } from 'react';
import { useFormState } from 'react-hook-form';

const useWarnOnUnsavedChanges = (isDirty: boolean) => {
  useEffect(() => {
    const handleBeforeUnload = (e: BeforeUnloadEvent) => {
      if (isDirty) {
        e.preventDefault();
        e.returnValue = ''; // Required for Chrome
      }
    };

    window.addEventListener('beforeunload', handleBeforeUnload);

    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, [isDirty]);
};

// Usage in step component
const StepWithWarning: React.FC<StepProps> = ({ onNext, onBack }) => {
  const { control, handleSubmit } = useForm();
  const { isDirty } = useFormState({ control });

  // Warn if user tries to leave with unsaved changes
  useWarnOnUnsavedChanges(isDirty);

  return (
    <form onSubmit={handleSubmit(onNext)}>
      {/* Form fields */}
    </form>
  );
};

โš ๏ธ Browser Limitations

The beforeunload event has limitations:

  • You can't customize the warning message (browser shows generic message)
  • It only works for browser navigation, not React Router navigation
  • Some browsers ignore it in certain contexts

For in-app navigation, use React Router's navigation guards or a confirmation dialog.

Step Completion Tracking

Track which steps have been completed so users can jump back to any completed step:

interface MultiStepFormState {
  currentStep: number;
  completedSteps: Set<number>;
  formData: any;
}

const MultiStepFormWithTracking: React.FC = () => {
  const [state, setState] = useState<MultiStepFormState>({
    currentStep: 1,
    completedSteps: new Set(),
    formData: {}
  });

  const handleStepComplete = (stepData: any) => {
    setState(prev => ({
      ...prev,
      completedSteps: new Set([...prev.completedSteps, prev.currentStep]),
      formData: { ...prev.formData, ...stepData },
      currentStep: prev.currentStep + 1
    }));
  };

  const canNavigateToStep = (step: number): boolean => {
    // Can navigate to current step or any completed step
    return step === state.currentStep || state.completedSteps.has(step);
  };

  const goToStep = (step: number) => {
    if (canNavigateToStep(step)) {
      setState(prev => ({ ...prev, currentStep: step }));
    }
  };

  return (
    <div>
      {/* Progress indicator with clickable steps */}
      <div className="progress-steps">
        {[1, 2, 3, 4].map(step => (
          <button
            key={step}
            onClick={() => goToStep(step)}
            disabled={!canNavigateToStep(step)}
            className={`
              step 
              ${step === state.currentStep ? 'active' : ''} 
              ${state.completedSteps.has(step) ? 'completed' : ''}
            `}
            aria-label={`Step ${step}${
              state.completedSteps.has(step) ? ' (completed)' : ''
            }${step === state.currentStep ? ' (current)' : ''}`}
          >
            {state.completedSteps.has(step) ? 'โœ“' : step}
          </button>
        ))}
      </div>

      {/* Render current step */}
      {/* ... */}
    </div>
  );
};

Scroll to Top on Step Change

When navigating between steps, scroll to the top of the form:

import { useEffect, useRef } from 'react';

const MultiStepFormWithScroll: React.FC = () => {
  const [currentStep, setCurrentStep] = useState(1);
  const formRef = useRef<HTMLDivElement>(null);

  // Scroll to top when step changes
  useEffect(() => {
    formRef.current?.scrollIntoView({ 
      behavior: 'smooth', 
      block: 'start' 
    });
  }, [currentStep]);

  return (
    <div ref={formRef}>
      {/* Form content */}
    </div>
  );
};

๐Ÿ’ก Keyboard Navigation

Make your multi-step forms keyboard-friendly:

  • Tab order: Ensure logical tab order through form fields
  • Enter key: Pressing Enter should submit the current step (if valid)
  • Escape key: Could cancel or go back (optional)
  • Arrow keys: Navigate between steps in progress indicator (optional)
const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === 'Escape' && currentStep > 1) {
    handleBack();
  }
};

๐Ÿ“ˆ Progress Indicators

Progress indicators are crucial for multi-step forms. They show users where they are, how far they've come, and how much is left. Let's explore different progress indicator patterns and implement them properly.

Types of Progress Indicators

1. Numbered Steps

const NumberedProgress: React.FC<{
  currentStep: number;
  totalSteps: number;
  completedSteps: Set<number>;
  onStepClick?: (step: number) => void;
}> = ({ currentStep, totalSteps, completedSteps, onStepClick }) => {
  return (
    <div className="numbered-progress">
      {Array.from({ length: totalSteps }, (_, i) => i + 1).map(step => {
        const isCompleted = completedSteps.has(step);
        const isCurrent = step === currentStep;
        const isClickable = isCompleted || isCurrent;

        return (
          <div key={step} className="progress-step-container">
            <button
              onClick={() => isClickable && onStepClick?.(step)}
              disabled={!isClickable}
              className={`
                progress-step
                ${isCurrent ? 'current' : ''}
                ${isCompleted ? 'completed' : ''}
              `}
              aria-current={isCurrent ? 'step' : undefined}
            >
              {isCompleted ? 'โœ“' : step}
            </button>
            {step < totalSteps && (
              <div 
                className={`progress-line ${
                  isCompleted ? 'completed' : ''
                }`}
              />
            )}
          </div>
        );
      })}
    </div>
  );
};

2. Progress Bar

const ProgressBar: React.FC<{
  currentStep: number;
  totalSteps: number;
}> = ({ currentStep, totalSteps }) => {
  const progress = ((currentStep - 1) / (totalSteps - 1)) * 100;

  return (
    <div className="progress-bar-container">
      <div className="progress-info">
        <span>Step {currentStep} of {totalSteps}</span>
        <span>{Math.round(progress)}% Complete</span>
      </div>
      <div 
        className="progress-bar-track"
        role="progressbar"
        aria-valuenow={progress}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-label={`Step ${currentStep} of ${totalSteps}`}
      >
        <div 
          className="progress-bar-fill"
          style={{ width: `${progress}%` }}
        />
      </div>
    </div>
  );
};

3. Step Labels

interface StepInfo {
  number: number;
  label: string;
  description?: string;
}

const LabeledProgress: React.FC<{
  steps: StepInfo[];
  currentStep: number;
  completedSteps: Set<number>;
}> = ({ steps, currentStep, completedSteps }) => {
  return (
    <div className="labeled-progress">
      {steps.map(step => {
        const isCompleted = completedSteps.has(step.number);
        const isCurrent = step.number === currentStep;

        return (
          <div
            key={step.number}
            className={`
              labeled-step
              ${isCurrent ? 'current' : ''}
              ${isCompleted ? 'completed' : ''}
            `}
          >
            <div className="step-indicator">
              {isCompleted ? 'โœ“' : step.number}
            </div>
            <div className="step-content">
              <div className="step-label">{step.label}</div>
              {step.description && (
                <div className="step-description">
                  {step.description}
                </div>
              )}
            </div>
          </div>
        );
      })}
    </div>
  );
};

// Usage
const steps: StepInfo[] = [
  { number: 1, label: 'Account', description: 'Create your account' },
  { number: 2, label: 'Profile', description: 'Tell us about yourself' },
  { number: 3, label: 'Preferences', description: 'Customize your experience' },
  { number: 4, label: 'Review', description: 'Confirm your information' }
];

Responsive Progress Indicators

On mobile devices, full step labels might not fit. Here's a responsive approach:

const ResponsiveProgress: React.FC<ProgressProps> = (props) => {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    const checkMobile = () => {
      setIsMobile(window.innerWidth < 768);
    };

    checkMobile();
    window.addEventListener('resize', checkMobile);
    return () => window.removeEventListener('resize', checkMobile);
  }, []);

  if (isMobile) {
    return <ProgressBar {...props} />;
  }

  return <LabeledProgress {...props} />;
};

โœ… Progress Indicator Best Practices

  • Always visible: Keep the progress indicator in view (sticky or fixed)
  • Clear current step: Make it obvious which step the user is on
  • Show completion: Visually distinguish completed steps
  • Clickable when allowed: Let users return to completed steps
  • Accessible: Use proper ARIA attributes for screen readers
  • Mobile-friendly: Adapt to smaller screens

CSS for Progress Indicators

/* Numbered Progress Styles */
.numbered-progress {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 2rem 0;
  position: sticky;
  top: 0;
  background: white;
  z-index: 100;
}

.progress-step-container {
  display: flex;
  align-items: center;
  flex: 1;
}

.progress-step {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 2px solid #ccc;
  background: white;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.3s ease;
}

.progress-step.current {
  border-color: #667eea;
  background: #667eea;
  color: white;
  box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.2);
}

.progress-step.completed {
  border-color: #4CAF50;
  background: #4CAF50;
  color: white;
}

.progress-step:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.progress-line {
  flex: 1;
  height: 2px;
  background: #e0e0e0;
  margin: 0 0.5rem;
  transition: background 0.3s ease;
}

.progress-line.completed {
  background: #4CAF50;
}

/* Progress Bar Styles */
.progress-bar-container {
  padding: 1rem 0;
  position: sticky;
  top: 0;
  background: white;
  z-index: 100;
}

.progress-info {
  display: flex;
  justify-content: space-between;
  margin-bottom: 0.5rem;
  font-size: 0.875rem;
  color: #666;
}

.progress-bar-track {
  height: 8px;
  background: #e0e0e0;
  border-radius: 4px;
  overflow: hidden;
}

.progress-bar-fill {
  height: 100%;
  background: linear-gradient(90deg, #667eea, #764ba2);
  transition: width 0.3s ease;
  border-radius: 4px;
}

/* Labeled Progress Styles */
.labeled-progress {
  display: flex;
  gap: 2rem;
  padding: 2rem 0;
}

.labeled-step {
  flex: 1;
  display: flex;
  gap: 1rem;
  opacity: 0.5;
  transition: opacity 0.3s ease;
}

.labeled-step.current,
.labeled-step.completed {
  opacity: 1;
}

.step-indicator {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 2px solid #ccc;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  flex-shrink: 0;
}

.labeled-step.current .step-indicator {
  border-color: #667eea;
  background: #667eea;
  color: white;
}

.labeled-step.completed .step-indicator {
  border-color: #4CAF50;
  background: #4CAF50;
  color: white;
}

.step-content {
  flex: 1;
}

.step-label {
  font-weight: 600;
  margin-bottom: 0.25rem;
}

.step-description {
  font-size: 0.875rem;
  color: #666;
}

/* Mobile Responsive */
@media (max-width: 768px) {
  .labeled-progress {
    flex-direction: column;
    gap: 1rem;
  }
  
  .numbered-progress {
    padding: 1rem 0;
  }
  
  .progress-step {
    width: 32px;
    height: 32px;
    font-size: 0.875rem;
  }
}

๐Ÿ’ก Animation Tips

Smooth transitions make progress indicators feel polished:

  • Animate the progress bar fill with CSS transitions
  • Add a subtle scale animation when completing a step
  • Use color transitions when changing step states
  • Consider a celebratory animation when reaching 100%
.progress-step.completed {
  animation: completePulse 0.6s ease;
}

@keyframes completePulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.1); }
}

๐Ÿ”€ Conditional Fields

Conditional fields show or hide based on the user's previous answers. This creates a dynamic, personalized form experience that only asks relevant questions. Think about forms that ask "Do you have pets?" and only show pet-related questions if you answer "Yes".

Basic Conditional Field Pattern

The simplest approach uses watch from React Hook Form to monitor field values:

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

interface FormData {
  hasPets: boolean;
  petType?: string;
  petName?: string;
}

const ConditionalFieldsBasic: React.FC = () => {
  const { register, watch, handleSubmit } = useForm<FormData>({
    defaultValues: {
      hasPets: false
    }
  });

  // Watch the hasPets field
  const hasPets = watch('hasPets');

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div className="form-group">
        <label>
          <input type="checkbox" {...register('hasPets')} />
          Do you have pets?
        </label>
      </div>

      {/* Conditional fields - only show if hasPets is true */}
      {hasPets && (
        <>
          <div className="form-group">
            <label htmlFor="petType">Type of Pet</label>
            <select id="petType" {...register('petType')}>
              <option value="">Select...</option>
              <option value="dog">Dog</option>
              <option value="cat">Cat</option>
              <option value="bird">Bird</option>
              <option value="other">Other</option>
            </select>
          </div>

          <div className="form-group">
            <label htmlFor="petName">Pet's Name</label>
            <input id="petName" {...register('petName')} />
          </div>
        </>
      )}

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

โœ… How watch() Works

The watch() function from React Hook Form:

  • Returns the current value of a field (or all fields if no name provided)
  • Re-renders the component when the watched field changes
  • Is more efficient than subscribing to form state manually
  • Can watch multiple fields: const [field1, field2] = watch(['field1', 'field2'])

Conditional Validation

When fields are conditional, their validation should be conditional too. Use Zod's refine or conditional schemas:

import { z } from 'zod';

// Approach 1: Using refine
const conditionalSchema = z.object({
  hasPets: z.boolean(),
  petType: z.string().optional(),
  petName: z.string().optional()
}).refine(
  (data) => {
    // If hasPets is true, petType and petName are required
    if (data.hasPets) {
      return data.petType && data.petName;
    }
    return true;
  },
  {
    message: 'Pet type and name are required when you have pets',
    path: ['petType'] // Which field to attach error to
  }
);

// Approach 2: Conditional schema with discriminated union
const schemaWithPets = z.object({
  hasPets: z.literal(true),
  petType: z.enum(['dog', 'cat', 'bird', 'other']),
  petName: z.string().min(1, 'Pet name is required')
});

const schemaWithoutPets = z.object({
  hasPets: z.literal(false)
});

const betterConditionalSchema = z.discriminatedUnion('hasPets', [
  schemaWithPets,
  schemaWithoutPets
]);

// Usage
const ConditionalValidation: React.FC = () => {
  const {
    register,
    watch,
    handleSubmit,
    formState: { errors }
  } = useForm<FormData>({
    resolver: zodResolver(conditionalSchema)
  });

  const hasPets = watch('hasPets');

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
      {hasPets && (
        <div className="form-group">
          <label htmlFor="petType">Type of Pet *</label>
          <select id="petType" {...register('petType')}>
            <option value="">Select...</option>
            <option value="dog">Dog</option>
            <option value="cat">Cat</option>
          </select>
          {errors.petType && (
            <span className="error">{errors.petType.message}</span>
          )}
        </div>
      )}
    </form>
  );
};

Multiple Conditional Levels

Sometimes conditionals are nestedโ€”field B appears if field A is true, and field C appears if field B has a certain value:

interface ShippingFormData {
  shippingMethod: 'standard' | 'express' | 'pickup';
  address?: string;
  expressDate?: string;
  pickupLocation?: string;
}

const NestedConditionals: React.FC = () => {
  const { register, watch } = useForm<ShippingFormData>();

  const shippingMethod = watch('shippingMethod');

  return (
    <form>
      <div className="form-group">
        <label>Shipping Method</label>
        <select {...register('shippingMethod')}>
          <option value="standard">Standard Shipping</option>
          <option value="express">Express Shipping</option>
          <option value="pickup">Store Pickup</option>
        </select>
      </div>

      {/* Show address for standard or express */}
      {(shippingMethod === 'standard' || shippingMethod === 'express') && (
        <div className="form-group">
          <label>Shipping Address</label>
          <input {...register('address')} />
        </div>
      )}

      {/* Show date picker only for express */}
      {shippingMethod === 'express' && (
        <div className="form-group">
          <label>Preferred Delivery Date</label>
          <input type="date" {...register('expressDate')} />
        </div>
      )}

      {/* Show pickup location only for pickup */}
      {shippingMethod === 'pickup' && (
        <div className="form-group">
          <label>Pickup Location</label>
          <select {...register('pickupLocation')}>
            <option>Downtown Store</option>
            <option>Mall Location</option>
            <option>Airport Store</option>
          </select>
        </div>
      )}
    </form>
  );
};

โš ๏ธ Performance Consideration

Each watch() call causes a re-render when the watched field changes. For forms with many conditional fields:

  • Watch only the fields you need
  • Consider watching all fields once: const values = watch()
  • Use useWatch hook for better performance in complex scenarios
  • Memoize expensive conditional calculations

Animated Conditional Fields

Make conditional fields appear smoothly with CSS transitions:

import { useState, useEffect } from 'react';

const AnimatedConditionalField: React.FC<{
  show: boolean;
  children: React.ReactNode;
}> = ({ show, children }) => {
  const [shouldRender, setShouldRender] = useState(show);

  useEffect(() => {
    if (show) setShouldRender(true);
  }, [show]);

  const handleAnimationEnd = () => {
    if (!show) setShouldRender(false);
  };

  if (!shouldRender) return null;

  return (
    <div
      className={`conditional-field ${show ? 'show' : 'hide'}`}
      onAnimationEnd={handleAnimationEnd}
    >
      {children}
    </div>
  );
};

// CSS
/*
.conditional-field {
  animation: slideDown 0.3s ease;
  overflow: hidden;
}

.conditional-field.hide {
  animation: slideUp 0.3s ease;
}

@keyframes slideDown {
  from {
    opacity: 0;
    max-height: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    max-height: 500px;
    transform: translateY(0);
  }
}

@keyframes slideUp {
  from {
    opacity: 1;
    max-height: 500px;
    transform: translateY(0);
  }
  to {
    opacity: 0;
    max-height: 0;
    transform: translateY(-10px);
  }
}
*/

๐Ÿ’ก UX Best Practices for Conditional Fields

  • Clear Cause-and-Effect: Make it obvious why fields appear/disappear
  • Smooth Transitions: Don't just show/hideโ€”animate the change
  • Preserve Scroll Position: Don't jump the page when fields appear
  • Don't Lose Data: If user fills conditional field, changes trigger, then changes backโ€”restore their data
  • Visual Grouping: Group conditional fields visually (border, background, indentation)
  • Accessibility: Announce changes to screen readers with ARIA live regions

โšก Interactive: Conditional Fields in Action

Experience how conditional fields work by interacting with this simulation. Notice how watch() tracks the employment status and dynamically shows or hides related fields:

๐Ÿ“‹ Employment Application Form
๐Ÿ” watch('employmentStatus')
employmentStatus: ""
Conditional Logic Results
status === 'employed'
status === 'self-employed'
status === 'student'
Visible Fields:
Base fields only
๐Ÿ’ก Key Insight: Each selection triggers a re-evaluation of all conditional expressions. Only the fields matching the current value are rendered.

โœ… What This Demonstrates

  • watch() Reactivity: The watch() function returns the current value and triggers re-renders when it changes
  • Conditional Rendering: Simple JavaScript comparison determines which JSX to render
  • Smooth Transitions: CSS animations make the show/hide feel natural
  • Visual Grouping: Conditional fields are visually distinguished with borders and background colors

๐Ÿงฉ Advanced Conditional Logic

Real-world forms often have complex conditional logic involving multiple fields, calculations, and business rules. Let's explore patterns for handling sophisticated conditional scenarios.

Boolean Logic Combinations

Sometimes fields should appear based on complex AND/OR conditions:

interface ApplicationFormData {
  applicationType: 'individual' | 'business' | 'nonprofit';
  annualRevenue: number;
  employeeCount: number;
  isInternational: boolean;
}

const ComplexConditionalForm: React.FC = () => {
  const { register, watch } = useForm<ApplicationFormData>();

  const applicationType = watch('applicationType');
  const annualRevenue = watch('annualRevenue');
  const employeeCount = watch('employeeCount');
  const isInternational = watch('isInternational');

  // Complex condition: Show tax ID field if...
  const shouldShowTaxId = 
    applicationType === 'business' || 
    applicationType === 'nonprofit' ||
    (applicationType === 'individual' && annualRevenue > 50000);

  // Show international fields if applicable
  const shouldShowInternationalFields =
    isInternational && (
      applicationType === 'business' || 
      employeeCount > 10
    );

  // Show certification field for large nonprofits
  const shouldShowCertification =
    applicationType === 'nonprofit' && 
    annualRevenue > 100000;

  return (
    <form>
      <div className="form-group">
        <label>Application Type</label>
        <select {...register('applicationType')}>
          <option value="individual">Individual</option>
          <option value="business">Business</option>
          <option value="nonprofit">Non-Profit</option>
        </select>
      </div>

      <div className="form-group">
        <label>Annual Revenue</label>
        <input 
          type="number" 
          {...register('annualRevenue', { valueAsNumber: true })} 
        />
      </div>

      {shouldShowTaxId && (
        <div className="form-group conditional-section">
          <label>Tax ID Number *</label>
          <input {...register('taxId')} />
          <small>Required for businesses and high-revenue individuals</small>
        </div>
      )}

      <div className="form-group">
        <label>
          <input type="checkbox" {...register('isInternational')} />
          International Operations
        </label>
      </div>

      {shouldShowInternationalFields && (
        <div className="conditional-section">
          <h3>International Information</h3>
          <div className="form-group">
            <label>Primary Country of Operation</label>
            <input {...register('primaryCountry')} />
          </div>
          <div className="form-group">
            <label>International Tax ID</label>
            <input {...register('internationalTaxId')} />
          </div>
        </div>
      )}

      {shouldShowCertification && (
        <div className="form-group conditional-section">
          <label>Non-Profit Certification Number *</label>
          <input {...register('certificationNumber')} />
          <small>Required for non-profits with revenue over $100,000</small>
        </div>
      )}
    </form>
  );
};

โœ… Clean Conditional Logic

Keep your conditional logic maintainable:

  • Extract to Variables: Give complex conditions descriptive names
  • Document Business Rules: Comment why conditions exist
  • Centralize Logic: Consider a custom hook for complex form rules
  • Test Thoroughly: Complex conditionals need thorough testing

Custom Hook for Conditional Logic

For very complex forms, extract conditional logic into a custom hook:

interface FormRules {
  shouldShowTaxId: boolean;
  shouldShowInternational: boolean;
  shouldShowCertification: boolean;
  shouldShowPaymentPlan: boolean;
}

const useFormConditionalRules = (
  formValues: ApplicationFormData
): FormRules => {
  const {
    applicationType,
    annualRevenue,
    employeeCount,
    isInternational
  } = formValues;

  // Calculate all conditional rules
  const rules: FormRules = useMemo(() => ({
    shouldShowTaxId:
      applicationType === 'business' || 
      applicationType === 'nonprofit' ||
      (applicationType === 'individual' && annualRevenue > 50000),

    shouldShowInternational:
      isInternational && (
        applicationType === 'business' || 
        employeeCount > 10
      ),

    shouldShowCertification:
      applicationType === 'nonprofit' && 
      annualRevenue > 100000,

    shouldShowPaymentPlan:
      annualRevenue > 0 && annualRevenue < 25000

  }), [applicationType, annualRevenue, employeeCount, isInternational]);

  return rules;
};

// Usage in component
const FormWithRulesHook: React.FC = () => {
  const { register, watch } = useForm<ApplicationFormData>();
  const formValues = watch();
  
  // Get all conditional rules from custom hook
  const rules = useFormConditionalRules(formValues);

  return (
    <form>
      {/* Base fields */}
      
      {rules.shouldShowTaxId && (
        <div>{/* Tax ID field */}</div>
      )}
      
      {rules.shouldShowInternational && (
        <div>{/* International fields */}</div>
      )}
      
      {rules.shouldShowCertification && (
        <div>{/* Certification field */}</div>
      )}
      
      {rules.shouldShowPaymentPlan && (
        <div>{/* Payment plan options */}</div>
      )}
    </form>
  );
};

Conditional Entire Steps

In multi-step forms, entire steps might be conditional:

const MultiStepWithConditionalSteps: React.FC = () => {
  const [currentStep, setCurrentStep] = useState(1);
  const [formData, setFormData] = useState<any>({});

  // Determine which steps to show based on data
  const getSteps = () => {
    const baseSteps = [
      { number: 1, component: AccountInfoStep, label: 'Account' },
      { number: 2, component: ShippingStep, label: 'Shipping' }
    ];

    // Add billing step only if shipping !== billing
    if (!formData.sameAsBilling) {
      baseSteps.push({
        number: 3,
        component: BillingStep,
        label: 'Billing'
      });
    }

    // Payment step (number depends on whether billing was shown)
    const paymentStepNumber = formData.sameAsBilling ? 3 : 4;
    baseSteps.push({
      number: paymentStepNumber,
      component: PaymentStep,
      label: 'Payment'
    });

    // Review is always last
    baseSteps.push({
      number: paymentStepNumber + 1,
      component: ReviewStep,
      label: 'Review'
    });

    return baseSteps;
  };

  const steps = getSteps();
  const totalSteps = steps.length;
  const currentStepData = steps.find(s => s.number === currentStep);

  const handleNext = (stepData: any) => {
    setFormData(prev => ({ ...prev, ...stepData }));
    
    if (currentStep < totalSteps) {
      setCurrentStep(prev => prev + 1);
    }
  };

  return (
    <div>
      <ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
      
      {currentStepData && (
        <currentStepData.component
          onNext={handleNext}
          onBack={() => setCurrentStep(prev => prev - 1)}
          defaultValues={formData}
        />
      )}
    </div>
  );
};

๐Ÿ’ก Dynamic Step Numbers

When steps are conditional, step numbers change dynamically. Consider:

  • Recalculate step numbers when formData changes
  • Update progress indicators to reflect actual total steps
  • Adjust navigation logic to skip over hidden steps
  • Clear data from skipped steps if those fields are no longer relevant

๐Ÿ”— Field Dependencies

Field dependencies occur when one field's value affects or determines another field's options, validation, or value. Common examples include country โ†’ state โ†’ city cascading dropdowns, or automatic calculations based on other fields.

Cascading Dropdowns

The classic example: selecting a country determines which states are available, and selecting a state determines which cities appear:

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

interface LocationData {
  country: string;
  state: string;
  city: string;
}

// Mock data - in real app, this would come from an API
const locationData = {
  USA: {
    California: ['Los Angeles', 'San Francisco', 'San Diego'],
    Texas: ['Houston', 'Dallas', 'Austin'],
    NewYork: ['New York City', 'Buffalo', 'Rochester']
  },
  Canada: {
    Ontario: ['Toronto', 'Ottawa', 'Mississauga'],
    Quebec: ['Montreal', 'Quebec City', 'Laval'],
    BritishColumbia: ['Vancouver', 'Victoria', 'Surrey']
  }
};

const CascadingDropdowns: React.FC = () => {
  const { register, watch, setValue, resetField } = useForm<LocationData>({
    defaultValues: {
      country: '',
      state: '',
      city: ''
    }
  });

  const selectedCountry = watch('country');
  const selectedState = watch('state');

  // Get available states based on selected country
  const availableStates = selectedCountry 
    ? Object.keys(locationData[selectedCountry as keyof typeof locationData] || {})
    : [];

  // Get available cities based on selected state
  const availableCities = selectedCountry && selectedState
    ? locationData[selectedCountry as keyof typeof locationData]?.[selectedState] || []
    : [];

  // Reset dependent fields when parent changes
  useEffect(() => {
    if (selectedCountry) {
      // Country changed - reset state and city
      resetField('state');
      resetField('city');
    }
  }, [selectedCountry, resetField]);

  useEffect(() => {
    if (selectedState) {
      // State changed - reset city
      resetField('city');
    }
  }, [selectedState, resetField]);

  return (
    <form>
      <div className="form-group">
        <label htmlFor="country">Country *</label>
        <select id="country" {...register('country', { required: true })}>
          <option value="">Select a country</option>
          <option value="USA">United States</option>
          <option value="Canada">Canada</option>
        </select>
      </div>

      <div className="form-group">
        <label htmlFor="state">State/Province *</label>
        <select 
          id="state" 
          {...register('state', { required: true })}
          disabled={!selectedCountry}
        >
          <option value="">
            {selectedCountry ? 'Select a state' : 'Select a country first'}
          </option>
          {availableStates.map(state => (
            <option key={state} value={state}>
              {state}
            </option>
          ))}
        </select>
      </div>

      <div className="form-group">
        <label htmlFor="city">City *</label>
        <select 
          id="city" 
          {...register('city', { required: true })}
          disabled={!selectedState}
        >
          <option value="">
            {selectedState ? 'Select a city' : 'Select a state first'}
          </option>
          {availableCities.map(city => (
            <option key={city} value={city}>
              {city}
            </option>
          ))}
        </select>
      </div>
    </form>
  );
};

โœ… Key Patterns for Cascading Dropdowns

  • Reset Dependent Fields: Use resetField() when parent changes
  • Disable Children: Disable dependent fields until parent is selected
  • Clear Messages: Tell users why a field is disabled
  • Preserve Valid Selections: Only reset if the selected value is no longer valid

Loading Dependent Data from API

In real applications, dependent data often comes from an API:

const DynamicCascadingDropdowns: React.FC = () => {
  const { register, watch, resetField } = useForm<LocationData>();
  const [states, setStates] = useState<string[]>([]);
  const [cities, setCities] = useState<string[]>([]);
  const [loadingStates, setLoadingStates] = useState(false);
  const [loadingCities, setLoadingCities] = useState(false);

  const selectedCountry = watch('country');
  const selectedState = watch('state');

  // Load states when country changes
  useEffect(() => {
    if (!selectedCountry) {
      setStates([]);
      return;
    }

    const loadStates = async () => {
      setLoadingStates(true);
      try {
        const response = await fetch(`/api/states?country=${selectedCountry}`);
        const data = await response.json();
        setStates(data);
      } catch (error) {
        console.error('Failed to load states:', error);
        setStates([]);
      } finally {
        setLoadingStates(false);
      }
    };

    loadStates();
    resetField('state');
    setCities([]);
    resetField('city');
  }, [selectedCountry, resetField]);

  // Load cities when state changes
  useEffect(() => {
    if (!selectedState) {
      setCities([]);
      return;
    }

    const loadCities = async () => {
      setLoadingCities(true);
      try {
        const response = await fetch(
          `/api/cities?country=${selectedCountry}&state=${selectedState}`
        );
        const data = await response.json();
        setCities(data);
      } catch (error) {
        console.error('Failed to load cities:', error);
        setCities([]);
      } finally {
        setLoadingCities(false);
      }
    };

    loadCities();
    resetField('city');
  }, [selectedState, selectedCountry, resetField]);

  return (
    <form>
      <div className="form-group">
        <label htmlFor="country">Country *</label>
        <select id="country" {...register('country')}>
          <option value="">Select a country</option>
          <option value="USA">United States</option>
          <option value="Canada">Canada</option>
        </select>
      </div>

      <div className="form-group">
        <label htmlFor="state">State/Province *</label>
        <select 
          id="state" 
          {...register('state')}
          disabled={!selectedCountry || loadingStates}
        >
          <option value="">
            {loadingStates ? 'Loading...' : 'Select a state'}
          </option>
          {states.map(state => (
            <option key={state} value={state}>{state}</option>
          ))}
        </select>
      </div>

      <div className="form-group">
        <label htmlFor="city">City *</label>
        <select 
          id="city" 
          {...register('city')}
          disabled={!selectedState || loadingCities}
        >
          <option value="">
            {loadingCities ? 'Loading...' : 'Select a city'}
          </option>
          {cities.map(city => (
            <option key={city} value={city}>{city}</option>
          ))}
        </select>
      </div>
    </form>
  );
};

Calculated Fields

Some fields automatically calculate based on other fields:

interface InvoiceData {
  quantity: number;
  unitPrice: number;
  subtotal: number;
  taxRate: number;
  taxAmount: number;
  total: number;
  discount: number;
}

const CalculatedFieldsForm: React.FC = () => {
  const { register, watch, setValue } = useForm<InvoiceData>({
    defaultValues: {
      quantity: 1,
      unitPrice: 0,
      taxRate: 10, // 10%
      discount: 0
    }
  });

  const quantity = watch('quantity');
  const unitPrice = watch('unitPrice');
  const taxRate = watch('taxRate');
  const discount = watch('discount');

  // Calculate derived values
  useEffect(() => {
    const subtotal = quantity * unitPrice;
    const discountAmount = (subtotal * discount) / 100;
    const subtotalAfterDiscount = subtotal - discountAmount;
    const taxAmount = (subtotalAfterDiscount * taxRate) / 100;
    const total = subtotalAfterDiscount + taxAmount;

    setValue('subtotal', subtotal);
    setValue('taxAmount', taxAmount);
    setValue('total', total);
  }, [quantity, unitPrice, taxRate, discount, setValue]);

  return (
    <form>
      <div className="form-row">
        <div className="form-group">
          <label htmlFor="quantity">Quantity</label>
          <input
            id="quantity"
            type="number"
            min="1"
            {...register('quantity', { valueAsNumber: true })}
          />
        </div>

        <div className="form-group">
          <label htmlFor="unitPrice">Unit Price</label>
          <input
            id="unitPrice"
            type="number"
            min="0"
            step="0.01"
            {...register('unitPrice', { valueAsNumber: true })}
          />
        </div>
      </div>

      <div className="form-group">
        <label htmlFor="discount">Discount (%)</label>
        <input
          id="discount"
          type="number"
          min="0"
          max="100"
          {...register('discount', { valueAsNumber: true })}
        />
      </div>

      <div className="form-group">
        <label htmlFor="taxRate">Tax Rate (%)</label>
        <input
          id="taxRate"
          type="number"
          min="0"
          max="100"
          step="0.1"
          {...register('taxRate', { valueAsNumber: true })}
        />
      </div>

      {/* Read-only calculated fields */}
      <div className="calculated-fields">
        <div className="form-group">
          <label>Subtotal</label>
          <input
            {...register('subtotal')}
            readOnly
            className="calculated"
          />
        </div>

        <div className="form-group">
          <label>Tax Amount</label>
          <input
            {...register('taxAmount')}
            readOnly
            className="calculated"
          />
        </div>

        <div className="form-group">
          <label>Total</label>
          <input
            {...register('total')}
            readOnly
            className="calculated total"
          />
        </div>
      </div>
    </form>
  );
};

๐Ÿ’ก Formatting Calculated Values

Display calculated values in user-friendly formats:

const formatCurrency = (value: number): string => {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD'
  }).format(value);
};

// Display formatted value
<div className="calculated-display">
  <span>Total: </span>
  <strong>{formatCurrency(watch('total'))}</strong>
</div>

Field Dependency Hook

Extract complex field dependency logic into a reusable hook:

interface DependencyConfig<T> {
  sourceField: keyof T;
  targetField: keyof T;
  calculate: (sourceValue: any) => any;
}

const useFieldDependency = <T extends Record<string, any>>(
  control: Control<T>,
  dependencies: DependencyConfig<T>[]
) => {
  const { setValue } = useFormContext<T>();

  dependencies.forEach(({ sourceField, targetField, calculate }) => {
    const sourceValue = useWatch({
      control,
      name: sourceField as string
    });

    useEffect(() => {
      if (sourceValue !== undefined) {
        const calculatedValue = calculate(sourceValue);
        setValue(targetField as string, calculatedValue);
      }
    }, [sourceValue, targetField, calculate, setValue]);
  });
};

// Usage
const FormWithDependencies: React.FC = () => {
  const { control } = useForm<InvoiceData>();

  useFieldDependency(control, [
    {
      sourceField: 'quantity',
      targetField: 'subtotal',
      calculate: (qty) => qty * watch('unitPrice')
    },
    // More dependencies...
  ]);

  return <form>{/* ... */}</form>;
};

โš ๏ธ Performance with Dependencies

Be mindful of performance when dealing with field dependencies:

  • Avoid Circular Dependencies: Field A โ†’ Field B โ†’ Field A creates infinite loops
  • Debounce Expensive Calculations: Don't recalculate on every keystroke
  • Memoize Complex Calculations: Use useMemo for expensive operations
  • Batch Updates: Update multiple dependent fields together

๐Ÿ’พ Auto-Save Implementation

Auto-save is a critical feature for long forms. It prevents data loss if users accidentally close the browser, lose connection, or navigate away. Let's implement robust auto-save functionality.

Basic Auto-Save to localStorage

import { useEffect } from 'react';
import { useForm } from 'react-hook-form';

const STORAGE_KEY = 'form-draft';
const AUTO_SAVE_DELAY = 2000; // 2 seconds

const AutoSaveForm: React.FC = () => {
  const { register, watch, reset } = useForm({
    defaultValues: () => {
      // Load saved data on mount
      const saved = localStorage.getItem(STORAGE_KEY);
      return saved ? JSON.parse(saved) : {};
    }
  });

  const formData = watch();

  // Auto-save to localStorage
  useEffect(() => {
    const timer = setTimeout(() => {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
      console.log('Form auto-saved');
    }, AUTO_SAVE_DELAY);

    return () => clearTimeout(timer);
  }, [formData]);

  const handleSubmit = (data: any) => {
    console.log('Submitting:', data);
    // Clear saved draft on successful submit
    localStorage.removeItem(STORAGE_KEY);
  };

  const clearDraft = () => {
    localStorage.removeItem(STORAGE_KEY);
    reset({});
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      
      <div className="form-actions">
        <button type="button" onClick={clearDraft}>
          Clear Draft
        </button>
        <button type="submit">
          Submit
        </button>
      </div>
    </form>
  );
};

โœ… How This Auto-Save Works

  1. Load on Mount: Check localStorage for saved draft when component mounts
  2. Watch All Fields: watch() without arguments watches all form fields
  3. Debounced Save: Use setTimeout to save after user stops typing
  4. Clear on Submit: Remove draft after successful submission

Auto-Save with Visual Feedback

Show users when their work is being saved:

type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';

const AutoSaveWithFeedback: React.FC = () => {
  const { register, watch, formState } = useForm();
  const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
  const [lastSaved, setLastSaved] = useState<Date | null>(null);

  const formData = watch();

  useEffect(() => {
    if (formState.isDirty) {
      setSaveStatus('saving');

      const timer = setTimeout(() => {
        try {
          localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
          setSaveStatus('saved');
          setLastSaved(new Date());
          
          // Reset to idle after 2 seconds
          setTimeout(() => setSaveStatus('idle'), 2000);
        } catch (error) {
          console.error('Auto-save failed:', error);
          setSaveStatus('error');
        }
      }, AUTO_SAVE_DELAY);

      return () => clearTimeout(timer);
    }
  }, [formData, formState.isDirty]);

  const formatLastSaved = () => {
    if (!lastSaved) return '';
    const seconds = Math.floor((Date.now() - lastSaved.getTime()) / 1000);
    if (seconds < 60) return 'just now';
    if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
    return lastSaved.toLocaleTimeString();
  };

  return (
    <div>
      {/* Save status indicator */}
      <div className={`save-status ${saveStatus}`}>
        {saveStatus === 'saving' && (
          <span>๐Ÿ’พ Saving...</span>
        )}
        {saveStatus === 'saved' && (
          <span>โœ“ Saved {formatLastSaved()}</span>
        )}
        {saveStatus === 'error' && (
          <span>โš ๏ธ Failed to save</span>
        )}
      </div>

      <form>
        {/* Form fields */}
      </form>
    </div>
  );
};

Auto-Save Hook

Create a reusable auto-save hook:

interface UseAutoSaveOptions {
  key: string;
  delay?: number;
  onSave?: (data: any) => void | Promise<void>;
  onError?: (error: Error) => void;
}

const useAutoSave = <T extends Record<string, any>>(
  formData: T,
  options: UseAutoSaveOptions
) => {
  const { key, delay = 2000, onSave, onError } = options;
  const [status, setStatus] = useState<SaveStatus>('idle');
  const [lastSaved, setLastSaved] = useState<Date | null>(null);

  useEffect(() => {
    setStatus('saving');

    const timer = setTimeout(async () => {
      try {
        // Save to localStorage
        localStorage.setItem(key, JSON.stringify(formData));

        // Call custom save function if provided
        if (onSave) {
          await onSave(formData);
        }

        setStatus('saved');
        setLastSaved(new Date());

        // Reset to idle after showing "saved" message
        setTimeout(() => setStatus('idle'), 2000);
      } catch (error) {
        console.error('Auto-save failed:', error);
        setStatus('error');
        if (onError && error instanceof Error) {
          onError(error);
        }
      }
    }, delay);

    return () => clearTimeout(timer);
  }, [formData, key, delay, onSave, onError]);

  const clearSaved = () => {
    localStorage.removeItem(key);
    setStatus('idle');
    setLastSaved(null);
  };

  return { status, lastSaved, clearSaved };
};

// Usage
const FormWithAutoSaveHook: React.FC = () => {
  const { register, watch } = useForm();
  const formData = watch();

  const { status, lastSaved } = useAutoSave(formData, {
    key: 'my-form-draft',
    delay: 2000,
    onSave: async (data) => {
      // Optional: save to server
      await fetch('/api/drafts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });
    },
    onError: (error) => {
      console.error('Save failed:', error);
      alert('Failed to save draft');
    }
  });

  return (
    <div>
      <SaveStatusIndicator status={status} lastSaved={lastSaved} />
      <form>{/* Form fields */}</form>
    </div>
  );
};

Auto-Save to Server

For critical data, save to your server instead of (or in addition to) localStorage:

const useServerAutoSave = <T,>(
  formData: T,
  draftId: string | null,
  options: {
    delay?: number;
    endpoint?: string;
  } = {}
) => {
  const { delay = 3000, endpoint = '/api/drafts' } = options;
  const [status, setStatus] = useState<SaveStatus>('idle');
  const [savedDraftId, setSavedDraftId] = useState<string | null>(draftId);

  useEffect(() => {
    setStatus('saving');

    const timer = setTimeout(async () => {
      try {
        const url = savedDraftId 
          ? `${endpoint}/${savedDraftId}` 
          : endpoint;
        
        const method = savedDraftId ? 'PUT' : 'POST';

        const response = await fetch(url, {
          method,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(formData)
        });

        if (!response.ok) {
          throw new Error('Server save failed');
        }

        const result = await response.json();
        
        // Save draft ID for future updates
        if (!savedDraftId && result.id) {
          setSavedDraftId(result.id);
        }

        setStatus('saved');
        setTimeout(() => setStatus('idle'), 2000);
      } catch (error) {
        console.error('Server auto-save failed:', error);
        setStatus('error');
      }
    }, delay);

    return () => clearTimeout(timer);
  }, [formData, savedDraftId, endpoint, delay]);

  return { status, draftId: savedDraftId };
};

๐Ÿ’ก Auto-Save Best Practices

  • Debounce: Don't save on every keystrokeโ€”wait for user to pause
  • Show Status: Always indicate saving/saved/error state
  • Handle Errors: Gracefully handle save failures, offer retry
  • Clear on Submit: Remove drafts after successful submission
  • Offline Support: Use localStorage as fallback when server is unavailable
  • Version Conflicts: Handle cases where same form is open in multiple tabs
  • Privacy: Clear sensitive data from localStorage after session ends

โš ๏ธ localStorage Limitations

  • Size Limit: Usually 5-10MB per domain
  • Security: Data is not encrypted, accessible via JavaScript
  • Synchronous: Can block the main thread with large data
  • Browser-Specific: Not shared across browsers or devices
  • Incognito Mode: Cleared when closing incognito window

For large or sensitive data, prefer server-side saving!

โšก Interactive: Auto-Save Debounce Timing

This visualization demonstrates how debouncing prevents excessive save operations. Type in the input field and watch how multiple rapid keystrokes get coalesced into a single save:

๐Ÿ“ Draft Your Message
Idle
0 chars
โฑ๏ธ Debounce Delay 2000ms
๐Ÿ“Š Event Timeline
Start typing to see events...
0
Keystrokes
0
Saves
--
Saved/Key

โœ… What This Demonstrates

  • Debounce Efficiency: Notice how many keystrokes result in a single save operation
  • Timer Reset: Each keystroke resets the wait timer, preventing premature saves
  • Adjustable Delay: Longer delays = fewer saves but more latency; find the right balance
  • User Feedback: Clear status indicators show the user what's happening

๐Ÿง™ Form Wizards

A form wizard is a specialized multi-step form that guides users through a specific process with clear instructions at each step. Wizards are great for complex workflows like product configuration, account setup, or onboarding.

Wizard vs. Regular Multi-Step Form

Aspect Multi-Step Form Form Wizard
Purpose Data collection Guided process
Navigation Linear, can go back Linear, sometimes can't skip ahead
Instructions Minimal Rich guidance at each step
Steps Usually 2-5 steps Can be 5-10+ steps
Complexity Simple to moderate Moderate to high

Complete Wizard Implementation

interface WizardStep {
  id: string;
  title: string;
  description: string;
  component: React.ComponentType<StepComponentProps>;
  canSkip?: boolean;
  icon?: string;
}

interface WizardContextValue {
  currentStepIndex: number;
  steps: WizardStep[];
  formData: any;
  goToStep: (index: number) => void;
  nextStep: (data: any) => void;
  previousStep: () => void;
  canGoToStep: (index: number) => boolean;
  isFirstStep: boolean;
  isLastStep: boolean;
  completedSteps: Set<number>;
}

const WizardContext = createContext<WizardContextValue | null>(null);

export const useWizard = () => {
  const context = useContext(WizardContext);
  if (!context) {
    throw new Error('useWizard must be used within WizardProvider');
  }
  return context;
};

const WizardProvider: React.FC<{
  steps: WizardStep[];
  onComplete: (data: any) => void;
  children: React.ReactNode;
}> = ({ steps, onComplete, children }) => {
  const [currentStepIndex, setCurrentStepIndex] = useState(0);
  const [formData, setFormData] = useState<any>({});
  const [completedSteps, setCompletedSteps] = useState<Set<number>>(
    new Set()
  );

  const isFirstStep = currentStepIndex === 0;
  const isLastStep = currentStepIndex === steps.length - 1;

  const canGoToStep = (index: number): boolean => {
    // Can go to current step or any completed step
    if (index === currentStepIndex) return true;
    if (completedSteps.has(index)) return true;
    
    // Can go to next step if current is completed
    if (index === currentStepIndex + 1 && completedSteps.has(currentStepIndex)) {
      return true;
    }
    
    return false;
  };

  const goToStep = (index: number) => {
    if (canGoToStep(index)) {
      setCurrentStepIndex(index);
    }
  };

  const nextStep = (stepData: any) => {
    // Save step data
    const updatedFormData = { ...formData, ...stepData };
    setFormData(updatedFormData);

    // Mark current step as completed
    setCompletedSteps(prev => new Set([...prev, currentStepIndex]));

    if (isLastStep) {
      // Submit complete form
      onComplete(updatedFormData);
    } else {
      // Move to next step
      setCurrentStepIndex(prev => prev + 1);
    }
  };

  const previousStep = () => {
    if (!isFirstStep) {
      setCurrentStepIndex(prev => prev - 1);
    }
  };

  const value: WizardContextValue = {
    currentStepIndex,
    steps,
    formData,
    goToStep,
    nextStep,
    previousStep,
    canGoToStep,
    isFirstStep,
    isLastStep,
    completedSteps
  };

  return (
    <WizardContext.Provider value={value}>
      {children}
    </WizardContext.Provider>
  );
};

// Main Wizard Component
const Wizard: React.FC<{
  steps: WizardStep[];
  onComplete: (data: any) => void;
}> = ({ steps, onComplete }) => {
  return (
    <WizardProvider steps={steps} onComplete={onComplete}>
      <WizardContainer />
    </WizardProvider>
  );
};

const WizardContainer: React.FC = () => {
  const {
    currentStepIndex,
    steps,
    formData,
    completedSteps,
    goToStep,
    canGoToStep
  } = useWizard();

  const currentStep = steps[currentStepIndex];
  const StepComponent = currentStep.component;

  return (
    <div className="wizard-container">
      {/* Wizard Header */}
      <div className="wizard-header">
        <h1>Setup Wizard</h1>
        <p>Complete the steps below to finish setup</p>
      </div>

      {/* Step Progress */}
      <div className="wizard-progress">
        {steps.map((step, index) => (
          <button
            key={step.id}
            onClick={() => goToStep(index)}
            disabled={!canGoToStep(index)}
            className={`
              wizard-step
              ${index === currentStepIndex ? 'active' : ''}
              ${completedSteps.has(index) ? 'completed' : ''}
            `}
          >
            <div className="step-number">
              {completedSteps.has(index) ? 'โœ“' : index + 1}
            </div>
            <div className="step-info">
              <div className="step-title">{step.title}</div>
              <div className="step-description">{step.description}</div>
            </div>
          </button>
        ))}
      </div>

      {/* Current Step Content */}
      <div className="wizard-content">
        <div className="step-header">
          {currentStep.icon && (
            <span className="step-icon">{currentStep.icon}</span>
          )}
          <h2>{currentStep.title}</h2>
          <p>{currentStep.description}</p>
        </div>

        <StepComponent defaultValues={formData} />
      </div>
    </div>
  );
};

Step Component Interface

interface StepComponentProps {
  defaultValues?: any;
}

// Example step component
const AccountSetupStep: React.FC<StepComponentProps> = ({ defaultValues }) => {
  const { nextStep, previousStep, isFirstStep } = useWizard();
  
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm({
    defaultValues
  });

  const onSubmit = (data: any) => {
    nextStep(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
      
      <div className="wizard-actions">
        {!isFirstStep && (
          <button type="button" onClick={previousStep}>
            โ† Back
          </button>
        )}
        <button type="submit">
          Continue โ†’
        </button>
      </div>
    </form>
  );
};

โœ… Wizard Best Practices

  • Clear Progress: Show exactly where users are in the process
  • Skip Options: Allow skipping optional steps
  • Save Progress: Auto-save so users can return later
  • Review Step: Include a final review before submission
  • Help Text: Provide guidance at each step
  • Validation: Validate each step before proceeding
  • Exit Warning: Warn users if they try to leave incomplete wizard

Wizard with Review Step

const ReviewStep: React.FC<StepComponentProps> = () => {
  const { formData, previousStep, nextStep, goToStep, steps } = useWizard();

  const handleSubmit = () => {
    nextStep({}); // Triggers onComplete in WizardProvider
  };

  return (
    <div className="review-step">
      <h2>Review Your Information</h2>
      <p>Please review your information before submitting.</p>

      {/* Display all collected data */}
      {steps.slice(0, -1).map((step, index) => {
        const stepData = formData[step.id] || {};
        const hasData = Object.keys(stepData).length > 0;

        if (!hasData) return null;

        return (
          <div key={step.id} className="review-section">
            <div className="review-section-header">
              <h3>{step.title}</h3>
              <button
                type="button"
                onClick={() => goToStep(index)}
                className="edit-button"
              >
                Edit
              </button>
            </div>

            <dl className="review-data">
              {Object.entries(stepData).map(([key, value]) => (
                <div key={key} className="review-item">
                  <dt>{formatFieldName(key)}</dt>
                  <dd>{String(value)}</dd>
                </div>
              ))}
            </dl>
          </div>
        );
      })}

      <div className="wizard-actions">
        <button type="button" onClick={previousStep}>
          โ† Back
        </button>
        <button
          type="button"
          onClick={handleSubmit}
          className="btn-primary"
        >
          Submit
        </button>
      </div>
    </div>
  );
};

// Helper function
const formatFieldName = (key: string): string => {
  return key
    .replace(/([A-Z])/g, ' $1')
    .replace(/^./, (str) => str.toUpperCase())
    .trim();
};

๐Ÿ’ก Wizard CSS Tips

/* Wizard container */
.wizard-container {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem;
}

/* Progress steps */
.wizard-progress {
  display: flex;
  gap: 1rem;
  margin: 2rem 0;
  overflow-x: auto;
}

.wizard-step {
  flex: 1;
  min-width: 200px;
  padding: 1rem;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  background: white;
  cursor: pointer;
  transition: all 0.3s ease;
}

.wizard-step.active {
  border-color: #667eea;
  background: #f0f4ff;
}

.wizard-step.completed {
  border-color: #4CAF50;
}

.wizard-step:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Review step */
.review-section {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 1.5rem;
  margin-bottom: 1.5rem;
}

.review-section-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 1rem;
}

.review-data {
  display: grid;
  grid-template-columns: 150px 1fr;
  gap: 0.75rem;
}

.review-item dt {
  font-weight: 600;
  color: #666;
}

.review-item dd {
  margin: 0;
}