Skip to main content

🎣 Lesson 7.2: React Hook Form

In the previous lesson, you learned how to build complex forms from scratch with vanilla React. You discovered it takes a lot of code to handle validation, errors, performance, and dynamic fields. React Hook Form solves these problems with an elegant, performant API that reduces boilerplate dramatically. In this lesson, you'll learn how to use React Hook Form to build professional forms with minimal code while maintaining full type safety with TypeScript.

🎯 Learning Objectives

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

  • Understand why React Hook Form is better than vanilla form handling
  • Install and configure React Hook Form in a TypeScript project
  • Use the useForm hook to manage form state
  • Register inputs with proper TypeScript typing
  • Implement validation rules with built-in validators
  • Handle form submission and errors effectively
  • Type your forms properly with TypeScript
  • Build performant forms that don't re-render unnecessarily

Estimated Time: 60-75 minutes

Project: Refactor a complex form to use React Hook Form

πŸ“‘ In This Lesson

πŸ€” Why React Hook Form?

In Lesson 7.1, you built forms the hard wayβ€”managing state, validation, errors, and performance manually. While this taught you the fundamentals, it's a lot of work for every form. Let's compare vanilla React form handling with React Hook Form:

The Pain of Vanilla Forms

Remember all the code you wrote in the previous lesson?

// SO. MUCH. CODE. 😩
function VanillaForm() {
  // State for each field
  const [formData, setFormData] = useState({ name: '', email: '', phone: '' });
  
  // Separate error state
  const [errors, setErrors] = useState<Record<string, string>>({});
  
  // Separate touched state
  const [touched, setTouched] = useState<Record<string, boolean>>({});
  
  // Loading state
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  // Change handlers
  const handleChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormData(prev => ({ ...prev, [field]: e.target.value }));
    if (errors[field]) {
      setErrors(prev => ({ ...prev, [field]: undefined }));
    }
  };
  
  // Blur handlers
  const handleBlur = (field: string) => () => {
    setTouched(prev => ({ ...prev, [field]: true }));
    // Run validation...
  };
  
  // Validation functions
  const validateName = (name: string) => {
    if (!name) return 'Name is required';
    if (name.length < 2) return 'Name must be at least 2 characters';
  };
  
  // More validation functions...
  
  // Submit handler
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    // Validate everything...
    // Set errors...
    // Submit if valid...
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={handleChange('name')}
        onBlur={handleBlur('name')}
      />
      {errors.name && touched.name && <span>{errors.name}</span>}
      {/* Repeat for every field... */}
    </form>
  );
}

❌ Problems with Vanilla Form Handling

  • Too much boilerplate - 100+ lines for a simple form
  • Manual state management - Track data, errors, touched, loading
  • Performance issues - Every keystroke causes re-renders
  • Repetitive code - Similar patterns for every field
  • Complex validation - Manual validation logic for each field
  • Error handling - Manually sync errors with fields

The React Hook Form Solution

Now let's see the same form with React Hook Form:

// SO. MUCH. CLEANER. πŸŽ‰
import { useForm } from 'react-hook-form';

interface FormData {
  name: string;
  email: string;
  phone: string;
}

function ReactHookFormExample() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
  
  const onSubmit = (data: FormData) => {
    console.log(data); // Already validated!
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('name', {
          required: 'Name is required',
          minLength: { value: 2, message: 'Name must be at least 2 characters' }
        })}
      />
      {errors.name && <span>{errors.name.message}</span>}
      
      <input
        {...register('email', {
          required: 'Email is required',
          pattern: { value: /^\S+@\S+$/i, message: 'Invalid email' }
        })}
      />
      {errors.email && <span>{errors.email.message}</span>}
      
      <button type="submit">Submit</button>
    </form>
  );
}

βœ… React Hook Form Benefits

  • Minimal boilerplate - 90% less code for the same functionality
  • Automatic state management - No manual useState needed
  • Excellent performance - Uncontrolled inputs = no re-renders on every keystroke
  • Built-in validation - Declarative validation rules
  • TypeScript support - Full type safety out of the box
  • Smaller bundle size - Only ~9KB minified + gzipped
  • Easy integration - Works with UI libraries and validation schemas

How React Hook Form Achieves Performance

React Hook Form uses uncontrolled components under the hood. This means inputs manage their own state internally (like regular HTML inputs), and React Hook Form uses refs to read values only when needed (on blur, on change, or on submit).

⚑ Interactive: Re-render Comparison

Type in the input fields to see how re-renders differ between controlled and uncontrolled forms

❌ Controlled (Vanilla React)
Re-renders: 0

⚠️ Every keystroke re-renders ALL fields

βœ… Uncontrolled (React Hook Form)
Re-renders: 0

✨ Zero React re-renders while typing!

0
Controlled Re-renders
vs
0
Uncontrolled Re-renders

πŸ’‘ What you're seeing: In controlled forms (vanilla React), every keystroke updates state, causing React to re-render the entire form component and all its children. With React Hook Form's uncontrolled approach, inputs manage their own state internallyβ€”React doesn't need to re-render at all until you submit or trigger validation.

graph LR A[User types] --> B[Input updates itself] B --> C[No React re-render!] C --> D[Value stored in ref] D --> E[Validation on demand] E --> F[Submit gets current values] style A fill:#667eea,color:#fff style C fill:#48bb78,color:#fff style F fill:#48bb78,color:#fff

πŸ’‘ Performance Comparison

Approach Re-renders on Keystroke Form with 20 Fields
Vanilla React (Controlled) Every field 20 re-renders per keystroke
React Hook Form None (until validation/submit) 0 re-renders per keystroke πŸš€

When to Use React Hook Form

βœ… Perfect For:

  • Forms with 5+ fields
  • Forms with complex validation rules
  • Dynamic forms with field arrays
  • Performance-critical applications
  • TypeScript projects (excellent type support)
  • Forms that integrate with validation libraries (Zod, Yup)

❌ Maybe Overkill For:

  • Single-field forms (search boxes)
  • Forms with 1-2 fields and no validation
  • Quick prototypes where setup time matters more than performance

πŸ“¦ Installation and Setup

Let's get React Hook Form installed and set up in your project.

Installation

Install React Hook Form using npm or yarn:

# Using npm
npm install react-hook-form

# Using yarn
yarn add react-hook-form

# Using pnpm
pnpm add react-hook-form

πŸ’‘ Version Information

As of this writing, React Hook Form v7 is the latest stable version. It's compatible with:

  • React 16.8+ (any version with hooks)
  • React Native
  • TypeScript 4.0+
  • All modern browsers

Importing React Hook Form

The main hook you'll use is useForm. Here's how to import it:

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

// You can also import other utilities
import { 
  useForm,           // Main hook
  useFieldArray,     // For dynamic fields
  useWatch,          // Watch specific fields
  Controller,        // For controlled components
  FormProvider       // For context
} from 'react-hook-form';

Basic Setup

Here's the simplest possible React Hook Form setup:

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

function SimpleForm() {
  // Initialize the form
  const { register, handleSubmit } = useForm();
  
  // Handle form submission
  const onSubmit = (data: any) => {
    console.log(data);
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('firstName')} />
      <input {...register('lastName')} />
      <button type="submit">Submit</button>
    </form>
  );
}

βœ… What Just Happened?

  1. useForm() initializes the form and returns utilities
  2. register() connects each input to the form
  3. handleSubmit() wraps your submit handler
  4. When submitted, onSubmit receives all form values as an object

TypeScript Configuration

For TypeScript projects, define your form data interface and pass it to useForm:

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

// Define your form data structure
interface LoginFormData {
  email: string;
  password: string;
  rememberMe: boolean;
}

function LoginForm() {
  // Pass the type to useForm
  const { register, handleSubmit } = useForm<LoginFormData>();
  
  // Now onSubmit has proper types!
  const onSubmit = (data: LoginFormData) => {
    console.log(data.email);     // βœ… TypeScript knows this exists
    console.log(data.password);  // βœ… Fully typed
    console.log(data.rememberMe); // βœ… boolean type
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register('email')} />
      <input type="password" {...register('password')} />
      <input type="checkbox" {...register('rememberMe')} />
      <button type="submit">Login</button>
    </form>
  );
}

πŸ’‘ TypeScript Benefits

  • Autocomplete - IDE suggests field names when calling register()
  • Type safety - Catches typos in field names at compile time
  • Intellisense - Know what properties exist on your form data
  • Refactoring - Change form structure safely with TypeScript's help
πŸ‹οΈ Exercise 1: Install and Setup

Task: Set up React Hook Form in your project and create a simple contact form.

  1. Install React Hook Form: npm install react-hook-form
  2. Create a new component called ContactForm.tsx
  3. Define an interface for the form data with fields: name, email, message
  4. Use useForm with your interface
  5. Create a form with three inputs
  6. Log the form data when submitted
πŸ’‘ Hint

Start with the type definition, then call useForm<YourType>(). Use the spread operator with register on each input: {...register('fieldName')}.

βœ… Solution
import { useForm } from 'react-hook-form';

interface ContactFormData {
  name: string;
  email: string;
  message: string;
}

function ContactForm() {
  const { register, handleSubmit } = useForm<ContactFormData>();
  
  const onSubmit = (data: ContactFormData) => {
    console.log('Form submitted:', data);
    alert(`Thanks ${data.name}! We'll contact you at ${data.email}`);
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="name" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Name
        </label>
        <input
          id="name"
          {...register('name')}
          style={{ width: '100%', padding: '0.5rem' }}
        />
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Email
        </label>
        <input
          id="email"
          type="email"
          {...register('email')}
          style={{ width: '100%', padding: '0.5rem' }}
        />
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="message" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Message
        </label>
        <textarea
          id="message"
          {...register('message')}
          rows={5}
          style={{ width: '100%', padding: '0.5rem', fontFamily: 'inherit' }}
        />
      </div>

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

export default ContactForm;

🎬 Your First React Hook Form

Let's build a complete registration form step by step to understand how all the pieces work together.

Step 1: Define Your Form Data

Always start by defining the shape of your form data with TypeScript:

interface RegistrationFormData {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  age: number;
  terms: boolean;
}

Step 2: Initialize useForm

Call useForm with your type and destructure the utilities you need:

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

function RegistrationForm() {
  const {
    register,        // Function to register inputs
    handleSubmit,    // Function to wrap your submit handler
    formState,       // Object containing form state
    watch,           // Function to watch field values
    reset            // Function to reset the form
  } = useForm<RegistrationFormData>({
    defaultValues: {
      username: '',
      email: '',
      password: '',
      confirmPassword: '',
      age: 18,
      terms: false
    }
  });
  
  // Access errors from formState
  const { errors, isSubmitting, isValid } = formState;
  
  // Rest of component...
}

πŸ’‘ useForm Configuration

The useForm hook accepts a configuration object with many options:

  • defaultValues - Initial form values
  • mode - When to validate ('onBlur', 'onChange', 'onSubmit', 'all')
  • reValidateMode - When to revalidate after errors
  • resolver - External validation schema (for Zod, Yup, etc.)

Step 3: Register Your Inputs

Use the register function to connect inputs to the form:

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    {/* Spread the register result onto your input */}
    <input
      {...register('username')}
      placeholder="Username"
    />
    
    <input
      type="email"
      {...register('email')}
      placeholder="Email"
    />
    
    <input
      type="password"
      {...register('password')}
      placeholder="Password"
    />
    
    <input
      type="number"
      {...register('age', { valueAsNumber: true })}
      placeholder="Age"
    />
    
    <label>
      <input
        type="checkbox"
        {...register('terms')}
      />
      I agree to the terms
    </label>
    
    <button type="submit">Register</button>
  </form>
);

βœ… What register Does

When you spread {...register('fieldName')} on an input, it adds:

  • name attribute
  • ref to track the input
  • onChange handler
  • onBlur handler

All of these work together to manage the field without you writing any code!

Step 4: Handle Submission

Create your submit handler and wrap it with handleSubmit:

const onSubmit = async (data: RegistrationFormData) => {
  try {
    // Data is already validated at this point!
    console.log('Form data:', data);
    
    // Make API call
    const response = await fetch('/api/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    
    if (response.ok) {
      alert('Registration successful!');
      reset(); // Clear the form
    }
  } catch (error) {
    console.error('Registration failed:', error);
  }
};

πŸ’‘ Submit Handler Guarantees

Your onSubmit function only runs if:

  • βœ… All validation rules pass
  • βœ… No errors exist
  • βœ… The form is valid

If validation fails, onSubmit won't be called and errors will be shown automatically!

Complete Registration Form Example

Let's put it all together into a complete, working registration form:

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

interface RegistrationFormData {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  age: number;
  terms: boolean;
}

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
    watch
  } = useForm<RegistrationFormData>({
    defaultValues: {
      username: '',
      email: '',
      password: '',
      confirmPassword: '',
      age: 18,
      terms: false
    }
  });

  const onSubmit = async (data: RegistrationFormData) => {
    console.log('Form submitted:', data);
    
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    alert('Registration successful!');
    reset(); // Clear form after success
  };

  // Watch password for confirm password validation
  const password = watch('password');

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <h2>Create Account</h2>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="username" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Username *
        </label>
        <input
          id="username"
          {...register('username', {
            required: 'Username is required',
            minLength: { value: 3, message: 'Username must be at least 3 characters' }
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.username ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.username && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.username.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Email *
        </label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message: 'Invalid email address'
            }
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.email && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Password *
        </label>
        <input
          id="password"
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: { value: 8, message: 'Password must be at least 8 characters' },
            pattern: {
              value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
              message: 'Password must contain uppercase, lowercase, and number'
            }
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.password && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.password.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="confirmPassword" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Confirm Password *
        </label>
        <input
          id="confirmPassword"
          type="password"
          {...register('confirmPassword', {
            required: 'Please confirm your password',
            validate: value => value === password || 'Passwords do not match'
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.confirmPassword ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.confirmPassword && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.confirmPassword.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="age" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Age *
        </label>
        <input
          id="age"
          type="number"
          {...register('age', {
            required: 'Age is required',
            valueAsNumber: true,
            min: { value: 18, message: 'You must be at least 18 years old' },
            max: { value: 120, message: 'Please enter a valid age' }
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.age && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.age.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
          <input
            type="checkbox"
            {...register('terms', {
              required: 'You must agree to the terms and conditions'
            })}
          />
          I agree to the terms and conditions *
        </label>
        {errors.terms && (
          <span style={{ color: '#f56565', fontSize: '0.875rem', display: 'block', marginTop: '0.25rem' }}>
            {errors.terms.message}
          </span>
        )}
      </div>

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

export default RegistrationForm;

βœ… What This Example Demonstrates

  • TypeScript typing - Full type safety for form data
  • Validation rules - Required, minLength, pattern, custom validation
  • Error display - Show errors next to fields
  • Visual feedback - Red borders on invalid fields
  • Cross-field validation - Password confirmation using watch
  • Loading state - Disable button during submission
  • Form reset - Clear form after successful submission
πŸ‹οΈ Exercise 2: Build Your First Form

Task: Create a login form with React Hook Form.

Requirements:

  • Fields: email, password, remember me (checkbox)
  • Email validation (required + email format)
  • Password validation (required + min 6 characters)
  • Show error messages below each field
  • Disable submit button while submitting
  • Console.log the form data on submit
πŸ’‘ Hint

Start with the interface. Use register with validation options. Access errors from formState. Use isSubmitting to disable the button.

βœ… Solution
import { useForm } from 'react-hook-form';

interface LoginFormData {
  email: string;
  password: string;
  rememberMe: boolean;
}

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<LoginFormData>({
    defaultValues: {
      email: '',
      password: '',
      rememberMe: false
    }
  });

  const onSubmit = async (data: LoginFormData) => {
    console.log('Login data:', data);
    
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    
    alert(`Welcome back! Remember me: ${data.rememberMe}`);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '400px', margin: '2rem auto' }}>
      <h2>Login</h2>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Email
        </label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Please enter a valid email'
            }
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.email && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Password
        </label>
        <input
          id="password"
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 6,
              message: 'Password must be at least 6 characters'
            }
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        {errors.password && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.password.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
          <input type="checkbox" {...register('rememberMe')} />
          Remember me
        </label>
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        style={{
          width: '100%',
          padding: '0.75rem',
          background: isSubmitting ? '#ddd' : '#667eea',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: isSubmitting ? 'not-allowed' : 'pointer'
        }}
      >
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

export default LoginForm;

🎣 The useForm Hook

The useForm hook is the heart of React Hook Form. Let's explore everything it returns and how to use each piece.

useForm Return Values

When you call useForm(), it returns an object with many utilities:

const {
  // Field registration
  register,           // Register inputs
  unregister,         // Unregister inputs
  
  // Form submission
  handleSubmit,       // Wrap your submit handler
  
  // Form state
  formState,          // Object with errors, isDirty, isValid, etc.
  
  // Watching values
  watch,              // Watch field values
  getValues,          // Get current values
  
  // Setting values
  setValue,           // Set a field value
  reset,              // Reset form
  
  // Validation
  trigger,            // Manually trigger validation
  clearErrors,        // Clear specific errors
  setError,           // Manually set errors
  
  // Form control
  control             // For Controller component
} = useForm<FormData>();

πŸ’‘ Most Commonly Used

You won't need all of these for every form. The most common are:

  • register - Connect inputs (used in 100% of forms)
  • handleSubmit - Handle form submission (100%)
  • formState - Access errors and form state (95%)
  • watch - Watch field values for dependent logic (50%)
  • reset - Clear form after submission (40%)
  • setValue - Programmatically set values (30%)

formState Deep Dive

The formState object contains valuable information about your form's current state:

const { formState } = useForm<FormData>();

// Destructure what you need
const {
  errors,           // Object with all field errors
  isDirty,          // True if any field has been modified
  dirtyFields,      // Object showing which fields are dirty
  touchedFields,    // Object showing which fields have been touched
  isSubmitted,      // True if form has been submitted
  isSubmitting,     // True during submission
  isValid,          // True if form has no errors
  isValidating,     // True during validation
  submitCount       // Number of times form was submitted
} = formState;

Practical Examples

function FormWithState() {
  const {
    register,
    handleSubmit,
    formState: { errors, isDirty, isValid, isSubmitting }
  } = useForm<{ email: string; password: string }>({
    mode: 'onChange' // Validate on change
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email', { required: true })} />
      <input {...register('password', { required: true })} />
      
      {/* Disable submit until form is valid and not already submitting */}
      <button 
        type="submit" 
        disabled={!isValid || isSubmitting}
      >
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
      
      {/* Show save status */}
      {isDirty && (
        <p style={{ color: 'orange' }}>You have unsaved changes</p>
      )}
      
      {/* Show error count */}
      {Object.keys(errors).length > 0 && (
        <p style={{ color: 'red' }}>
          Please fix {Object.keys(errors).length} error(s)
        </p>
      )}
    </form>
  );
}

watch() - Watching Field Values

The watch function lets you observe field values, useful for dependent fields or conditional rendering:

function ConditionalForm() {
  const { register, watch } = useForm<{
    hasCompany: boolean;
    companyName: string;
    companySize: string;
  }>();

  // Watch a single field
  const hasCompany = watch('hasCompany');
  
  // Watch multiple fields
  const [hasCompany2, companyName] = watch(['hasCompany', 'companyName']);
  
  // Watch all fields
  const allValues = watch();

  return (
    <form>
      <label>
        <input type="checkbox" {...register('hasCompany')} />
        I have a company
      </label>

      {/* Conditionally show fields based on checkbox */}
      {hasCompany && (
        <>
          <input
            {...register('companyName', { required: 'Company name is required' })}
            placeholder="Company Name"
          />
          
          <select {...register('companySize')}>
            <option value="">Select size</option>
            <option value="1-10">1-10 employees</option>
            <option value="11-50">11-50 employees</option>
            <option value="50+">50+ employees</option>
          </select>
        </>
      )}
    </form>
  );
}

⚠️ Performance Note

Calling watch() without arguments watches ALL fields, which can cause re-renders. For better performance:

  • βœ… Watch specific fields: watch('fieldName')
  • βœ… Watch subset: watch(['field1', 'field2'])
  • ❌ Avoid: watch() (watches everything)

setValue() - Programmatically Setting Values

Sometimes you need to set form values programmatically (e.g., from API data):

function EditProfileForm() {
  const { register, setValue, handleSubmit } = useForm<{
    name: string;
    email: string;
    bio: string;
  }>();

  // Load user data when component mounts
  useEffect(() => {
    async function loadUserData() {
      const userData = await fetchUserProfile();
      
      // Set multiple values
      setValue('name', userData.name);
      setValue('email', userData.email);
      setValue('bio', userData.bio);
      
      // Or use reset to set all at once
      // reset(userData);
    }
    
    loadUserData();
  }, [setValue]);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      <input {...register('email')} />
      <textarea {...register('bio')} />
      <button type="submit">Save</button>
    </form>
  );
}

reset() - Resetting the Form

Clear the form or reset it to specific values:

function FormWithReset() {
  const { register, handleSubmit, reset } = useForm<{
    title: string;
    description: string;
  }>({
    defaultValues: {
      title: '',
      description: ''
    }
  });

  const onSubmit = async (data: any) => {
    await saveData(data);
    
    // Reset to default values (empty)
    reset();
    
    // OR reset to specific values
    reset({
      title: '',
      description: 'Default description'
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('title')} />
      <textarea {...register('description')} />
      
      <button type="submit">Submit</button>
      <button type="button" onClick={() => reset()}>
        Clear Form
      </button>
    </form>
  );
}

βœ… When to Use Each Method

Method Use Case Example
watch Observe field values Show/hide fields based on checkbox
getValues Get values without re-render Access values in onClick handler
setValue Set single field value Update one field from API
reset Set multiple values or clear form Load form from API or clear after submit
πŸ‹οΈ Exercise 3: Using useForm Features

Task: Create a shipping form with conditional address fields.

Requirements:

  • Checkbox: "Billing address same as shipping"
  • If checked, hide billing address fields
  • Use watch to observe the checkbox
  • Show a message if form has unsaved changes (isDirty)
  • Disable submit button if form is invalid
πŸ’‘ Hint

Use watch('sameAddress') to watch the checkbox. Access isDirty and isValid from formState. Conditionally render billing fields with {!sameAddress && ...}.

βœ… Solution
import { useForm } from 'react-hook-form';

interface ShippingFormData {
  shippingAddress: string;
  shippingCity: string;
  sameAddress: boolean;
  billingAddress?: string;
  billingCity?: string;
}

function ShippingForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isDirty, isValid }
  } = useForm<ShippingFormData>({
    mode: 'onChange',
    defaultValues: {
      shippingAddress: '',
      shippingCity: '',
      sameAddress: true,
      billingAddress: '',
      billingCity: ''
    }
  });

  const sameAddress = watch('sameAddress');

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

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <h2>Shipping Information</h2>

      {/* Unsaved changes warning */}
      {isDirty && (
        <div style={{ 
          background: '#fff3cd', 
          padding: '0.75rem', 
          marginBottom: '1rem',
          borderRadius: '4px'
        }}>
          ⚠️ You have unsaved changes
        </div>
      )}

      {/* Shipping Address */}
      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="shippingAddress">Shipping Address *</label>
        <input
          id="shippingAddress"
          {...register('shippingAddress', { required: 'Shipping address is required' })}
          style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
        />
        {errors.shippingAddress && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.shippingAddress.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="shippingCity">City *</label>
        <input
          id="shippingCity"
          {...register('shippingCity', { required: 'City is required' })}
          style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
        />
        {errors.shippingCity && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.shippingCity.message}
          </span>
        )}
      </div>

      {/* Same address checkbox */}
      <div style={{ marginBottom: '1rem' }}>
        <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
          <input type="checkbox" {...register('sameAddress')} />
          Billing address same as shipping
        </label>
      </div>

      {/* Conditional Billing Address */}
      {!sameAddress && (
        <>
          <h3>Billing Address</h3>
          
          <div style={{ marginBottom: '1rem' }}>
            <label htmlFor="billingAddress">Billing Address *</label>
            <input
              id="billingAddress"
              {...register('billingAddress', { 
                required: !sameAddress && 'Billing address is required' 
              })}
              style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
            />
            {errors.billingAddress && (
              <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                {errors.billingAddress.message}
              </span>
            )}
          </div>

          <div style={{ marginBottom: '1rem' }}>
            <label htmlFor="billingCity">City *</label>
            <input
              id="billingCity"
              {...register('billingCity', { 
                required: !sameAddress && 'City is required' 
              })}
              style={{ width: '100%', padding: '0.5rem', marginTop: '0.5rem' }}
            />
            {errors.billingCity && (
              <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
                {errors.billingCity.message}
              </span>
            )}
          </div>
        </>
      )}

      <button
        type="submit"
        disabled={!isValid}
        style={{
          width: '100%',
          padding: '0.75rem',
          background: isValid ? '#667eea' : '#ddd',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: isValid ? 'pointer' : 'not-allowed'
        }}
      >
        Submit Order
      </button>
    </form>
  );
}

export default ShippingForm;

πŸ“ Registering Inputs

The register function is how you connect HTML inputs to React Hook Form. Let's explore all the ways to use it.

Basic Registration

The simplest form of registration just needs the field name:

function BasicRegistration() {
  const { register } = useForm<{ email: string }>();
  
  return (
    <input {...register('email')} />
  );
}

When you spread register('email'), it adds these props to your input:

// What register returns:
{
  name: 'email',
  ref: (instance) => { /* stores ref to input */ },
  onChange: (e) => { /* updates form state */ },
  onBlur: (e) => { /* triggers validation */ }
}

Registration with Validation

Pass a second argument with validation rules:

function ValidationExample() {
  const { register } = useForm<{
    username: string;
    email: string;
    age: number;
  }>();
  
  return (
    <form>
      {/* Required field */}
      <input
        {...register('username', {
          required: 'Username is required'
        })}
      />
      
      {/* Multiple validation rules */}
      <input
        {...register('email', {
          required: 'Email is required',
          pattern: {
            value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
            message: 'Invalid email format'
          },
          minLength: {
            value: 5,
            message: 'Email must be at least 5 characters'
          }
        })}
      />
      
      {/* Number validation */}
      <input
        type="number"
        {...register('age', {
          required: 'Age is required',
          valueAsNumber: true,  // Convert to number
          min: {
            value: 18,
            message: 'Must be at least 18'
          },
          max: {
            value: 120,
            message: 'Please enter a valid age'
          }
        })}
      />
    </form>
  );
}

πŸ’‘ Built-in Validation Rules

Rule Type Example
required boolean | string required: 'This is required'
min number | object min: { value: 0, message: '...' }
max number | object max: { value: 100, message: '...' }
minLength number | object minLength: { value: 3, message: '...' }
maxLength number | object maxLength: { value: 20, message: '...' }
pattern RegExp | object pattern: { value: /regex/, message: '...' }
validate function | object validate: value => value !== 'test'

Different Input Types

React Hook Form works with all HTML input types:

function AllInputTypes() {
  const { register } = useForm();
  
  return (
    <form>
      {/* Text input */}
      <input type="text" {...register('name')} />
      
      {/* Email input */}
      <input type="email" {...register('email')} />
      
      {/* Number input */}
      <input 
        type="number" 
        {...register('age', { valueAsNumber: true })} 
      />
      
      {/* Checkbox */}
      <input type="checkbox" {...register('agree')} />
      
      {/* Radio buttons */}
      <input type="radio" value="male" {...register('gender')} />
      <input type="radio" value="female" {...register('gender')} />
      
      {/* Select dropdown */}
      <select {...register('country')}>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
      </select>
      
      {/* Textarea */}
      <textarea {...register('bio')} />
      
      {/* Date input */}
      <input 
        type="date" 
        {...register('birthDate', { valueAsDate: true })} 
      />
      
      {/* File input */}
      <input type="file" {...register('avatar')} />
    </form>
  );
}

βœ… Value Type Conversion

  • valueAsNumber - Convert to number (for type="number")
  • valueAsDate - Convert to Date object (for type="date")
  • setValueAs - Custom transformation function

Without these, all values are strings by default!

Custom Validation Functions

For complex validation logic, use the validate option with a custom function:

function CustomValidation() {
  const { register, watch } = useForm<{
    password: string;
    confirmPassword: string;
    username: string;
  }>();

  const password = watch('password');

  return (
    <form>
      {/* Single validation function */}
      <input
        {...register('username', {
          validate: (value) => {
            if (value.toLowerCase() === 'admin') {
              return 'Username "admin" is not allowed';
            }
            return true; // Valid
          }
        })}
      />

      <input
        type="password"
        {...register('password')}
      />

      {/* Validate against another field */}
      <input
        type="password"
        {...register('confirmPassword', {
          validate: (value) => 
            value === password || 'Passwords do not match'
        })}
      />
    </form>
  );
}

Multiple Custom Validators

You can have multiple validation functions for a single field:

function MultipleValidators() {
  const { register } = useForm<{ email: string }>();

  return (
    <input
      {...register('email', {
        required: 'Email is required',
        validate: {
          // Named validators for specific error messages
          matchPattern: (value) =>
            /^\S+@\S+$/i.test(value) || 'Email must be valid',
          
          notDisposable: (value) =>
            !value.endsWith('@tempmail.com') || 'Disposable emails not allowed',
          
          notBlacklisted: async (value) => {
            const isBlacklisted = await checkEmailBlacklist(value);
            return !isBlacklisted || 'This email is blacklisted';
          }
        }
      })}
    />
  );
}

πŸ’‘ Validation Function Return Values

  • true - Validation passed
  • false - Validation failed (generic error)
  • string - Validation failed with this error message
  • Promise<true | false | string> - Async validation
πŸ‹οΈ Exercise 4: Custom Validation

Task: Create a password strength validator.

Requirements:

  • Password must be at least 8 characters
  • Must contain at least one uppercase letter
  • Must contain at least one lowercase letter
  • Must contain at least one number
  • Must contain at least one special character (!@#$%^&*)
  • Use multiple named validators
πŸ’‘ Hint

Use the validate option with an object containing named functions. Test each requirement with a regular expression.

βœ… Solution
import { useForm } from 'react-hook-form';

interface PasswordFormData {
  password: string;
}

function PasswordStrengthForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<PasswordFormData>();

  const onSubmit = (data: PasswordFormData) => {
    console.log('Strong password:', data.password);
    alert('Password is strong!');
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '400px', margin: '2rem auto' }}>
      <h2>Create Password</h2>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="password" style={{ display: 'block', marginBottom: '0.5rem' }}>
          Password
        </label>
        <input
          id="password"
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters'
            },
            validate: {
              hasUppercase: (value) =>
                /[A-Z]/.test(value) || 'Password must contain an uppercase letter',
              
              hasLowercase: (value) =>
                /[a-z]/.test(value) || 'Password must contain a lowercase letter',
              
              hasNumber: (value) =>
                /\d/.test(value) || 'Password must contain a number',
              
              hasSpecial: (value) =>
                /[!@#$%^&*]/.test(value) || 'Password must contain a special character (!@#$%^&*)'
            }
          })}
          style={{
            width: '100%',
            padding: '0.5rem',
            border: `1px solid ${errors.password ? '#f56565' : '#ddd'}`,
            borderRadius: '4px'
          }}
        />
        
        {/* Show all password errors */}
        {errors.password && (
          <div style={{ marginTop: '0.5rem' }}>
            <span style={{ color: '#f56565', fontSize: '0.875rem', display: 'block' }}>
              {errors.password.message}
            </span>
          </div>
        )}

        {/* Password requirements checklist */}
        <div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
          <p style={{ marginBottom: '0.25rem' }}><strong>Requirements:</strong></p>
          <ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
            <li>At least 8 characters</li>
            <li>One uppercase letter</li>
            <li>One lowercase letter</li>
            <li>One number</li>
            <li>One special character (!@#$%^&*)</li>
          </ul>
        </div>
      </div>

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

export default PasswordStrengthForm;

βœ… Validation Rules

React Hook Form provides powerful validation capabilities. Let's explore all the validation options and strategies.

Validation Modes

Control when validation occurs by setting the mode option in useForm:

const { register } = useForm<FormData>({
  mode: 'onSubmit',    // Default: Validate on submit
  // mode: 'onBlur',   // Validate when field loses focus
  // mode: 'onChange', // Validate on every change
  // mode: 'onTouched',// Validate after blur, then on change
  // mode: 'all'       // Validate on blur and change
});

Validation Mode Comparison

Mode When It Validates Best For
onSubmit Only when form is submitted Simple forms, less intrusive
onBlur When field loses focus Most forms, good UX balance
onChange On every keystroke Real-time feedback (password strength)
onTouched After blur, then on every change Best UX - validate after user leaves field
all On blur AND on change Maximum validation, can be annoying

βœ… Recommended Mode

For the best user experience, use mode: 'onTouched' or mode: 'onBlur':

  • Doesn't annoy users while they're typing
  • Validates after they finish with a field
  • Provides immediate feedback on subsequent changes
  • Balances helpfulness with intrusiveness

Advanced Validation Patterns

1. Dependent Field Validation

Validate one field based on another field's value:

function DependentValidation() {
  const { register, watch } = useForm<{
    startDate: string;
    endDate: string;
    minPrice: number;
    maxPrice: number;
  }>();

  const startDate = watch('startDate');
  const minPrice = watch('minPrice');

  return (
    <form>
      <input type="date" {...register('startDate', { required: true })} />
      
      <input
        type="date"
        {...register('endDate', {
          required: 'End date is required',
          validate: (value) => {
            if (!startDate) return true;
            return value >= startDate || 'End date must be after start date';
          }
        })}
      />

      <input
        type="number"
        {...register('minPrice', {
          required: true,
          valueAsNumber: true,
          min: { value: 0, message: 'Price must be positive' }
        })}
      />

      <input
        type="number"
        {...register('maxPrice', {
          required: true,
          valueAsNumber: true,
          validate: (value) => 
            value >= minPrice || 'Max price must be greater than min price'
        })}
      />
    </form>
  );
}

2. Async Validation

Validate against a server (e.g., check username availability):

function AsyncValidation() {
  const { register, formState: { errors } } = useForm<{ username: string }>();

  // Simulate API call
  const checkUsernameAvailability = async (username: string): Promise<boolean> => {
    await new Promise(resolve => setTimeout(resolve, 500));
    return !['admin', 'root', 'test'].includes(username.toLowerCase());
  };

  return (
    <div>
      <input
        {...register('username', {
          required: 'Username is required',
          minLength: { value: 3, message: 'At least 3 characters' },
          validate: async (value) => {
            const isAvailable = await checkUsernameAvailability(value);
            return isAvailable || 'Username is already taken';
          }
        })}
      />
      {errors.username && <span>{errors.username.message}</span>}
    </div>
  );
}

⚠️ Async Validation Performance

Async validation can be expensive. Best practices:

  • Use mode: 'onBlur' to avoid validating on every keystroke
  • Debounce the validation if using onChange mode
  • Show a loading indicator during validation
  • Cache results to avoid duplicate API calls

3. Conditional Validation

Apply validation rules only when certain conditions are met:

function ConditionalValidation() {
  const { register, watch } = useForm<{
    needsVisa: boolean;
    visaNumber: string;
    country: string;
  }>();

  const needsVisa = watch('needsVisa');
  const country = watch('country');

  return (
    <form>
      <select {...register('country')}>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
        <option value="cn">China</option>
      </select>

      <label>
        <input type="checkbox" {...register('needsVisa')} />
        I need a visa
      </label>

      {/* Only validate if checkbox is checked */}
      {needsVisa && (
        <input
          {...register('visaNumber', {
            required: needsVisa && 'Visa number is required',
            pattern: needsVisa && country === 'us' ? {
              value: /^\d{9}$/,
              message: 'US visa must be 9 digits'
            } : undefined
          })}
        />
      )}
    </form>
  );
}

4. Array Validation

Validate array fields (like multiple email addresses):

function ArrayValidation() {
  const { register } = useForm<{ emails: string }>();

  return (
    <input
      {...register('emails', {
        required: 'At least one email is required',
        validate: (value) => {
          // Split comma-separated emails
          const emails = value.split(',').map(e => e.trim());
          
          // Check if all are valid
          const emailRegex = /^\S+@\S+$/i;
          const allValid = emails.every(email => emailRegex.test(email));
          
          if (!allValid) {
            return 'All emails must be valid';
          }
          
          // Check for duplicates
          const unique = new Set(emails);
          if (unique.size !== emails.length) {
            return 'Duplicate emails are not allowed';
          }
          
          return true;
        }
      })}
      placeholder="Enter emails separated by commas"
    />
  );
}

Manual Validation Triggering

Sometimes you need to trigger validation programmatically:

function ManualValidation() {
  const {
    register,
    trigger,
    getValues,
    formState: { errors }
  } = useForm<{
    email: string;
    password: string;
  }>();

  const handleCheckEmail = async () => {
    // Validate just the email field
    const isValid = await trigger('email');
    
    if (isValid) {
      console.log('Email is valid:', getValues('email'));
    }
  };

  const handleCheckAll = async () => {
    // Validate all fields
    const isValid = await trigger();
    
    if (isValid) {
      console.log('All fields are valid');
    }
  };

  return (
    <form>
      <input
        {...register('email', {
          required: 'Email is required',
          pattern: { value: /^\S+@\S+$/, message: 'Invalid email' }
        })}
      />
      <button type="button" onClick={handleCheckEmail}>
        Check Email
      </button>
      
      <input
        {...register('password', {
          required: 'Password is required'
        })}
      />
      
      <button type="button" onClick={handleCheckAll}>
        Validate All
      </button>
      
      {Object.keys(errors).length > 0 && (
        <div style={{ color: 'red' }}>
          {Object.keys(errors).length} error(s) found
        </div>
      )}
    </form>
  );
}

πŸ’‘ trigger() Use Cases

  • Multi-step forms - Validate current step before proceeding
  • Partial validation - Check specific fields on button click
  • Debounced validation - Validate after user stops typing
  • Manual testing - Validate on custom events

❗ Error Handling

React Hook Form makes error handling straightforward. Let's explore different ways to display and manage errors.

Accessing Errors

Errors are available in the formState.errors object:

function ErrorExample() {
  const {
    register,
    formState: { errors }
  } = useForm<{
    username: string;
    email: string;
  }>();

  return (
    <form>
      <input {...register('username', { required: 'Username is required' })} />
      
      {/* Check if error exists */}
      {errors.username && (
        <span style={{ color: 'red' }}>
          {errors.username.message}
        </span>
      )}

      <input {...register('email', { required: 'Email is required' })} />
      
      {/* Alternative: Optional chaining */}
      {errors.email?.message && (
        <span style={{ color: 'red' }}>
          {errors.email.message}
        </span>
      )}
    </form>
  );
}

Error Object Structure

Each error has the following properties:

// Error object structure
type FieldError = {
  type: string;      // Type of error (e.g., 'required', 'minLength')
  message?: string;  // Error message
  ref?: Ref;         // Reference to the input element
};

// Example usage
if (errors.username) {
  console.log(errors.username.type);    // 'required'
  console.log(errors.username.message); // 'Username is required'
}

Different Error Display Patterns

Pattern 1: Inline Errors (Most Common)

function InlineErrors() {
  const {
    register,
    formState: { errors }
  } = useForm<{ email: string }>();

  return (
    <div>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        {...register('email', {
          required: 'Email is required',
          pattern: { value: /^\S+@\S+$/, message: 'Invalid email' }
        })}
        aria-invalid={errors.email ? 'true' : 'false'}
        aria-describedby="email-error"
      />
      {errors.email && (
        <span
          id="email-error"
          role="alert"
          style={{ color: '#f56565', fontSize: '0.875rem', display: 'block', marginTop: '0.25rem' }}
        >
          {errors.email.message}
        </span>
      )}
    </div>
  );
}

Pattern 2: Error Summary

function ErrorSummary() {
  const {
    register,
    formState: { errors }
  } = useForm<{
    name: string;
    email: string;
    password: string;
  }>();

  const errorCount = Object.keys(errors).length;

  return (
    <form>
      {/* Error summary at top */}
      {errorCount > 0 && (
        <div
          role="alert"
          style={{
            background: '#ffebee',
            border: '1px solid #f56565',
            padding: '1rem',
            borderRadius: '4px',
            marginBottom: '1rem'
          }}
        >
          <h3 style={{ margin: '0 0 0.5rem 0', color: '#f56565' }}>
            ⚠️ Please fix {errorCount} error{errorCount !== 1 ? 's' : ''}:
          </h3>
          <ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
            {Object.entries(errors).map(([field, error]) => (
              <li key={field}>
                <strong>{field}:</strong> {error?.message}
              </li>
            ))}
          </ul>
        </div>
      )}

      <input {...register('name', { required: 'Name is required' })} />
      <input {...register('email', { required: 'Email is required' })} />
      <input {...register('password', { required: 'Password is required' })} />
      
      <button type="submit">Submit</button>
    </form>
  );
}

Pattern 3: Reusable Error Component

// Reusable error display component
interface ErrorMessageProps {
  error?: FieldError;
}

function ErrorMessage({ error }: ErrorMessageProps) {
  if (!error) return null;

  return (
    <span
      role="alert"
      style={{
        color: '#f56565',
        fontSize: '0.875rem',
        display: 'flex',
        alignItems: 'center',
        gap: '0.25rem',
        marginTop: '0.25rem'
      }}
    >
      <span aria-hidden="true">⚠️</span>
      {error.message}
    </span>
  );
}

// Usage
function FormWithErrorComponent() {
  const {
    register,
    formState: { errors }
  } = useForm<{ username: string; email: string }>();

  return (
    <form>
      <div>
        <input {...register('username', { required: 'Username is required' })} />
        <ErrorMessage error={errors.username} />
      </div>

      <div>
        <input {...register('email', { required: 'Email is required' })} />
        <ErrorMessage error={errors.email} />
      </div>
    </form>
  );
}

Manual Error Management

You can manually set and clear errors:

function ManualErrors() {
  const {
    register,
    handleSubmit,
    setError,
    clearErrors,
    formState: { errors }
  } = useForm<{ email: string; password: string }>();

  const onSubmit = async (data: any) => {
    try {
      const response = await loginAPI(data);
      
      if (!response.ok) {
        // Set error manually
        setError('email', {
          type: 'manual',
          message: 'Invalid credentials'
        });
        
        // Or set a root error (not tied to specific field)
        setError('root.serverError', {
          type: 'manual',
          message: 'Server is unavailable. Please try again later.'
        });
      }
    } catch (error) {
      setError('root.serverError', {
        type: 'manual',
        message: 'Network error. Please check your connection.'
      });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Display root errors */}
      {errors.root?.serverError && (
        <div style={{ background: '#ffebee', padding: '1rem', marginBottom: '1rem' }}>
          {errors.root.serverError.message}
        </div>
      )}

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

      <input type="password" {...register('password')} />

      <button type="submit">Login</button>
      
      {/* Clear all errors */}
      <button type="button" onClick={() => clearErrors()}>
        Clear Errors
      </button>
    </form>
  );
}

βœ… Error Handling Best Practices

  • Always use aria-invalid on inputs with errors
  • Use role="alert" on error messages for screen readers
  • Link errors to inputs with aria-describedby
  • Show errors in red with sufficient contrast (4.5:1)
  • Include helpful, specific error messages
  • Clear errors when user starts fixing them
  • Use icons for visual feedback (not just color)
πŸ‹οΈ Exercise 5: Complete Form with Errors

Task: Build a profile update form with comprehensive error handling.

Requirements:

  • Fields: name, email, age, bio
  • All fields required with appropriate validation
  • Show inline errors below each field
  • Show error summary at top if errors exist
  • Disable submit button if form has errors
  • Simulate server error on submit and display it
πŸ’‘ Hint

Access errors from formState. Count errors with Object.keys(errors).length. Use setError for server errors in the submit handler.

βœ… Solution
import { useForm } from 'react-hook-form';

interface ProfileFormData {
  name: string;
  email: string;
  age: number;
  bio: string;
}

function ProfileUpdateForm() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isValid, isSubmitting }
  } = useForm<ProfileFormData>({
    mode: 'onBlur',
    defaultValues: {
      name: '',
      email: '',
      age: 18,
      bio: ''
    }
  });

  const onSubmit = async (data: ProfileFormData) => {
    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      // Simulate server error randomly
      if (Math.random() > 0.5) {
        setError('root.serverError', {
          type: 'manual',
          message: 'Failed to update profile. Please try again.'
        });
        return;
      }
      
      console.log('Profile updated:', data);
      alert('Profile updated successfully!');
    } catch (error) {
      setError('root.serverError', {
        type: 'manual',
        message: 'Network error. Please check your connection.'
      });
    }
  };

  const errorCount = Object.keys(errors).filter(key => key !== 'root').length;

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <h2>Update Profile</h2>

      {/* Error Summary */}
      {errorCount > 0 && (
        <div
          role="alert"
          style={{
            background: '#ffebee',
            border: '1px solid #f56565',
            padding: '1rem',
            borderRadius: '4px',
            marginBottom: '1rem'
          }}
        >
          <h3 style={{ margin: '0 0 0.5rem 0', color: '#f56565' }}>
            ⚠️ Please fix {errorCount} error{errorCount !== 1 ? 's' : ''}
          </h3>
        </div>
      )}

      {/* Server Error */}
      {errors.root?.serverError && (
        <div
          role="alert"
          style={{
            background: '#ffebee',
            border: '1px solid #f56565',
            padding: '1rem',
            borderRadius: '4px',
            marginBottom: '1rem',
            color: '#f56565'
          }}
        >
          {errors.root.serverError.message}
        </div>
      )}

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="name">Name *</label>
        <input
          id="name"
          {...register('name', {
            required: 'Name is required',
            minLength: { value: 2, message: 'Name must be at least 2 characters' }
          })}
          aria-invalid={errors.name ? 'true' : 'false'}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.name ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.name && (
          <span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.name.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email">Email *</label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Invalid email address'
            }
          })}
          aria-invalid={errors.email ? 'true' : 'false'}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.email && (
          <span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="age">Age *</label>
        <input
          id="age"
          type="number"
          {...register('age', {
            required: 'Age is required',
            valueAsNumber: true,
            min: { value: 13, message: 'Must be at least 13 years old' },
            max: { value: 120, message: 'Please enter a valid age' }
          })}
          aria-invalid={errors.age ? 'true' : 'false'}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.age ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.age && (
          <span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.age.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="bio">Bio *</label>
        <textarea
          id="bio"
          {...register('bio', {
            required: 'Bio is required',
            minLength: { value: 10, message: 'Bio must be at least 10 characters' },
            maxLength: { value: 200, message: 'Bio must be less than 200 characters' }
          })}
          rows={4}
          aria-invalid={errors.bio ? 'true' : 'false'}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            fontFamily: 'inherit',
            border: `1px solid ${errors.bio ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.bio && (
          <span role="alert" style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.bio.message}
          </span>
        )}
      </div>

      <button
        type="submit"
        disabled={!isValid || isSubmitting}
        style={{
          width: '100%',
          padding: '0.75rem',
          background: (isValid && !isSubmitting) ? '#667eea' : '#ddd',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: (isValid && !isSubmitting) ? 'pointer' : 'not-allowed'
        }}
      >
        {isSubmitting ? 'Updating...' : 'Update Profile'}
      </button>
    </form>
  );
}

export default ProfileUpdateForm;

πŸ”· TypeScript Integration

React Hook Form has excellent TypeScript support. Let's explore advanced typing patterns to make your forms completely type-safe.

Basic Type Definition

We've already seen basic typing with useForm<FormData>. Let's go deeper:

// Define your form structure
interface UserFormData {
  name: string;
  email: string;
  age: number;
  role: 'admin' | 'user' | 'guest'; // Union type
  preferences: {
    newsletter: boolean;
    notifications: boolean;
  };
  tags: string[];
}

function TypedForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<UserFormData>({
    defaultValues: {
      name: '',
      email: '',
      age: 18,
      role: 'user',
      preferences: {
        newsletter: false,
        notifications: true
      },
      tags: []
    }
  });

  // onSubmit is fully typed!
  const onSubmit = (data: UserFormData) => {
    console.log(data.name);        // βœ… string
    console.log(data.age);         // βœ… number
    console.log(data.role);        // βœ… 'admin' | 'user' | 'guest'
    console.log(data.preferences); // βœ… { newsletter: boolean; notifications: boolean }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* TypeScript knows these field names exist */}
      <input {...register('name')} />
      <input {...register('email')} />
      <input {...register('age', { valueAsNumber: true })} />
      
      <select {...register('role')}>
        <option value="admin">Admin</option>
        <option value="user">User</option>
        <option value="guest">Guest</option>
      </select>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Type Inference

TypeScript can infer types from your default values:

// TypeScript infers the form type from defaultValues
function InferredForm() {
  const {
    register,
    handleSubmit
  } = useForm({
    defaultValues: {
      username: '',        // inferred as string
      age: 0,             // inferred as number
      isActive: false,    // inferred as boolean
      tags: [] as string[] // need to specify array type
    }
  });

  // data is automatically typed!
  const onSubmit = (data) => {
    console.log(data.username); // TypeScript knows this is a string
    console.log(data.age);      // TypeScript knows this is a number
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('username')} />
      <input {...register('age', { valueAsNumber: true })} />
      <button type="submit">Submit</button>
    </form>
  );
}

Nested Form Data

Type nested objects and arrays properly:

interface Address {
  street: string;
  city: string;
  country: string;
  zipCode: string;
}

interface WorkExperience {
  company: string;
  position: string;
  years: number;
}

interface ComplexFormData {
  personalInfo: {
    firstName: string;
    lastName: string;
    birthDate: Date;
  };
  address: Address;
  workHistory: WorkExperience[];
  skills: string[];
}

function NestedForm() {
  const { register, handleSubmit } = useForm<ComplexFormData>();

  const onSubmit = (data: ComplexFormData) => {
    // All nested properties are typed!
    console.log(data.personalInfo.firstName);
    console.log(data.address.city);
    console.log(data.workHistory[0].company);
    console.log(data.skills);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Nested object notation */}
      <input {...register('personalInfo.firstName')} />
      <input {...register('personalInfo.lastName')} />
      
      {/* Nested address */}
      <input {...register('address.street')} />
      <input {...register('address.city')} />
      
      {/* Arrays with index */}
      <input {...register('workHistory.0.company')} />
      <input {...register('workHistory.0.position')} />
      
      <button type="submit">Submit</button>
    </form>
  );
}

Generic Form Component

Create reusable form components that work with any data type:

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

// Generic form component
interface FormProps<TFormData extends FieldValues> {
  onSubmit: (data: TFormData) => void;
  defaultValues: TFormData;
  children: (methods: UseFormReturn<TFormData>) => React.ReactNode;
}

function GenericForm<TFormData extends FieldValues>({
  onSubmit,
  defaultValues,
  children
}: FormProps<TFormData>) {
  const methods = useForm<TFormData>({ defaultValues });

  return (
    <form onSubmit={methods.handleSubmit(onSubmit)}>
      {children(methods)}
    </form>
  );
}

// Usage with different data types
interface LoginData {
  email: string;
  password: string;
}

function LoginPage() {
  const handleLogin = (data: LoginData) => {
    console.log('Login:', data);
  };

  return (
    <GenericForm
      onSubmit={handleLogin}
      defaultValues={{ email: '', password: '' }}
    >
      {({ register, formState: { errors } }) => (
        <>
          <input {...register('email', { required: true })} />
          {errors.email && <span>Email required</span>}
          
          <input type="password" {...register('password', { required: true })} />
          {errors.password && <span>Password required</span>}
          
          <button type="submit">Login</button>
        </>
      )}
    </GenericForm>
  );
}

Strict Mode TypeScript

Make field names completely type-safe:

interface StrictFormData {
  username: string;
  email: string;
  age: number;
}

function StrictForm() {
  const { register } = useForm<StrictFormData>();

  return (
    <form>
      {/* βœ… TypeScript knows these are valid */}
      <input {...register('username')} />
      <input {...register('email')} />
      <input {...register('age')} />
      
      {/* ❌ TypeScript error: 'password' doesn't exist on StrictFormData */}
      {/* <input {...register('password')} /> */}
    </form>
  );
}

βœ… TypeScript Benefits Summary

  • Autocomplete - IDE suggests valid field names
  • Type safety - Catch typos at compile time
  • Refactoring - Rename fields safely across your app
  • Documentation - Types document your form structure
  • IntelliSense - See field types while coding
  • Fewer bugs - Many errors caught before runtime

Typing Validation Rules

Create typed validation helpers:

// Type-safe validation rules
type ValidationRules<T> = {
  [K in keyof T]?: {
    required?: string | boolean;
    minLength?: { value: number; message: string };
    maxLength?: { value: number; message: string };
    pattern?: { value: RegExp; message: string };
    validate?: (value: T[K]) => boolean | string;
  };
};

interface UserFormData {
  username: string;
  email: string;
  age: number;
}

// Define validation rules with full type safety
const validationRules: ValidationRules<UserFormData> = {
  username: {
    required: 'Username is required',
    minLength: { value: 3, message: 'At least 3 characters' }
  },
  email: {
    required: 'Email is required',
    pattern: {
      value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message: 'Invalid email'
    }
  },
  age: {
    required: 'Age is required',
    validate: (value) => value >= 18 || 'Must be 18 or older'
  }
};

function TypedValidationForm() {
  const { register } = useForm<UserFormData>();

  return (
    <form>
      <input {...register('username', validationRules.username)} />
      <input {...register('email', validationRules.email)} />
      <input {...register('age', validationRules.age)} />
    </form>
  );
}

πŸ’‘ Advanced TypeScript Patterns

  • Use Partial<FormData> for optional field updates
  • Use Pick<FormData, 'field1' | 'field2'> for subsets
  • Use Omit<FormData, 'field'> to exclude fields
  • Use Record<string, any> for dynamic forms
  • Use as const for literal types in validation

πŸ“€ Form Submission

Let's explore advanced form submission patterns, including loading states, success/error handling, and data transformation.

Basic Submission

The handleSubmit function wraps your submit handler:

function BasicSubmission() {
  const { register, handleSubmit } = useForm<{ name: string }>();

  // Your submit handler - only called if validation passes
  const onSubmit = (data: { name: string }) => {
    console.log('Form data:', data);
    // data is already validated and typed!
  };

  // Optional: Handle validation errors
  const onError = (errors: any) => {
    console.log('Validation errors:', errors);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit, onError)}>
      <input {...register('name', { required: true })} />
      <button type="submit">Submit</button>
    </form>
  );
}

Async Submission with Loading State

Handle asynchronous operations and show loading feedback:

function AsyncSubmission() {
  const {
    register,
    handleSubmit,
    formState: { isSubmitting, isSubmitSuccessful },
    reset
  } = useForm<{ email: string }>();

  const onSubmit = async (data: { email: string }) => {
    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 2000));
      
      const response = await fetch('/api/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
      });

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

      // Reset form on success
      reset();
      alert('Successfully subscribed!');
    } catch (error) {
      alert('Subscription failed. Please try again.');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('email', { required: true })}
        disabled={isSubmitting}
      />
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Subscribing...' : 'Subscribe'}
      </button>

      {isSubmitSuccessful && (
        <p style={{ color: 'green' }}>βœ“ Successfully subscribed!</p>
      )}
    </form>
  );
}

Data Transformation Before Submit

Transform form data before sending to the server:

interface FormInput {
  firstName: string;
  lastName: string;
  birthDate: string;
  tags: string; // comma-separated
}

interface APIPayload {
  fullName: string;
  birthDate: Date;
  tags: string[];
}

function TransformationForm() {
  const { register, handleSubmit } = useForm<FormInput>();

  const onSubmit = (data: FormInput) => {
    // Transform data for API
    const payload: APIPayload = {
      fullName: `${data.firstName} ${data.lastName}`,
      birthDate: new Date(data.birthDate),
      tags: data.tags.split(',').map(tag => tag.trim())
    };

    console.log('Transformed payload:', payload);
    // Send payload to API
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('firstName')} placeholder="First Name" />
      <input {...register('lastName')} placeholder="Last Name" />
      <input {...register('birthDate')} type="date" />
      <input {...register('tags')} placeholder="tag1, tag2, tag3" />
      <button type="submit">Submit</button>
    </form>
  );
}

Complete Submission Flow

A production-ready form with all submission states:

interface ContactFormData {
  name: string;
  email: string;
  message: string;
}

function CompleteSubmissionForm() {
  const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
    setError
  } = useForm<ContactFormData>({
    mode: 'onBlur'
  });

  const onSubmit = async (data: ContactFormData) => {
    try {
      setSubmitStatus('idle');

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

      if (!response.ok) {
        throw new Error('Failed to send message');
      }

      // Success
      setSubmitStatus('success');
      reset();

      // Hide success message after 5 seconds
      setTimeout(() => setSubmitStatus('idle'), 5000);
    } catch (error) {
      setSubmitStatus('error');
      setError('root.serverError', {
        type: 'manual',
        message: 'Failed to send message. Please try again later.'
      });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '500px', margin: '2rem auto' }}>
      <h2>Contact Us</h2>

      {/* Success Message */}
      {submitStatus === 'success' && (
        <div
          style={{
            background: '#e8f5e9',
            border: '1px solid #4CAF50',
            color: '#2e7d32',
            padding: '1rem',
            borderRadius: '4px',
            marginBottom: '1rem'
          }}
        >
          βœ“ Message sent successfully! We'll get back to you soon.
        </div>
      )}

      {/* Error Message */}
      {errors.root?.serverError && (
        <div
          style={{
            background: '#ffebee',
            border: '1px solid #f56565',
            color: '#f56565',
            padding: '1rem',
            borderRadius: '4px',
            marginBottom: '1rem'
          }}
        >
          ⚠️ {errors.root.serverError.message}
        </div>
      )}

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="name">Name *</label>
        <input
          id="name"
          {...register('name', {
            required: 'Name is required',
            minLength: { value: 2, message: 'Name must be at least 2 characters' }
          })}
          disabled={isSubmitting}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.name ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.name && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.name.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="email">Email *</label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Invalid email address'
            }
          })}
          disabled={isSubmitting}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            border: `1px solid ${errors.email ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.email && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <div style={{ marginBottom: '1rem' }}>
        <label htmlFor="message">Message *</label>
        <textarea
          id="message"
          {...register('message', {
            required: 'Message is required',
            minLength: { value: 10, message: 'Message must be at least 10 characters' }
          })}
          rows={5}
          disabled={isSubmitting}
          style={{
            width: '100%',
            padding: '0.5rem',
            marginTop: '0.5rem',
            fontFamily: 'inherit',
            border: `1px solid ${errors.message ? '#f56565' : '#ddd'}`
          }}
        />
        {errors.message && (
          <span style={{ color: '#f56565', fontSize: '0.875rem' }}>
            {errors.message.message}
          </span>
        )}
      </div>

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

βœ… Form Submission Best Practices

  • Disable inputs during submission - Prevent changes while processing
  • Show loading state - Update button text or show spinner
  • Handle errors gracefully - Display user-friendly error messages
  • Provide success feedback - Confirm submission with visual feedback
  • Reset form after success - Clear fields for new submission
  • Validate before submit - Use mode: 'onBlur' or 'onTouched'
  • Transform data if needed - Match API requirements
  • Use TypeScript - Type both form data and API payloads

πŸ“š Summary

Congratulations! You've mastered React Hook Form. Let's recap what you've learned:

Key Takeaways

  • βœ… Why React Hook Form - 90% less code, better performance, excellent DX
  • βœ… Installation - Simple npm install, works with any React project
  • βœ… useForm Hook - Returns register, handleSubmit, formState, and more
  • βœ… Registration - Spread {...register('fieldName')} on inputs
  • βœ… Validation - Built-in rules (required, min, max, pattern) + custom validators
  • βœ… Error Handling - Automatic error tracking with formState.errors
  • βœ… TypeScript - Full type safety for form data and validation
  • βœ… Submission - Async support, loading states, error handling

React Hook Form vs Vanilla React

Feature Vanilla React React Hook Form
Code for simple form ~100 lines ~20 lines
Performance Re-renders on every keystroke No re-renders (uncontrolled)
Validation Manual functions Built-in + custom
TypeScript Manual typing Automatic inference
Learning curve Moderate Easy
Bundle size 0KB (vanilla) ~9KB (minified + gzipped)

When to Use React Hook Form

βœ… Perfect For:

  • Any form with 3+ fields
  • Forms requiring validation
  • Complex forms with nested data
  • Performance-critical applications
  • TypeScript projects
  • Forms that will grow over time

⚠️ Consider Vanilla React When:

  • Single field forms (search boxes)
  • You need controlled inputs for specific reasons
  • Bundle size is extremely critical (<9KB matters)
  • Quick prototypes where setup time matters

Quick Reference

Common Patterns Cheat Sheet

// 1. Basic setup
const { register, handleSubmit } = useForm<FormData>();

// 2. Register with validation
<input {...register('email', {
  required: 'Email is required',
  pattern: { value: /^\S+@\S+$/, message: 'Invalid email' }
})} />

// 3. Show errors
{errors.email && <span>{errors.email.message}</span>}

// 4. Watch field values
const password = watch('password');

// 5. Custom validation
validate: (value) => value === password || 'Passwords must match'

// 6. Async validation
validate: async (value) => {
  const available = await checkAvailability(value);
  return available || 'Already taken';
}

// 7. Submit handler
const onSubmit = async (data: FormData) => {
  await saveData(data);
  reset();
};

// 8. Manual errors
setError('email', { type: 'manual', message: 'Server error' });

// 9. Trigger validation
await trigger('email'); // Validate single field
await trigger();        // Validate all fields

🎯 What's Next?

In the upcoming lessons, you'll learn:

  • Lesson 7.3: Form Validation with Zod - Schema-based validation for even more power
  • Lesson 7.4: File Uploads - Handle files, images, and previews
  • Lesson 7.5: Advanced Form Patterns - Dynamic fields, multi-step wizards, and more

React Hook Form integrates beautifully with Zod, which you'll learn in the next lesson!

πŸ’ͺ You've Learned

  • How React Hook Form simplifies form handling dramatically
  • The useForm hook and all its utilities
  • How to register inputs and add validation
  • Built-in and custom validation strategies
  • Multiple error display patterns
  • Advanced TypeScript integration for type safety
  • Production-ready form submission patterns
  • When to use React Hook Form vs vanilla React

You're now ready to build professional, performant forms with React Hook Form!