React Forms - Complete Guide

React Forms - Complete Guide

Forms are essential for user interaction in web applications. React provides powerful tools for creating and managing forms, from simple input fields to complex multi-step forms. This guide covers everything you need to know about building forms in React.

Controlled vs Uncontrolled Components

In React, form elements can be either controlled or uncontrolled. Understanding the difference is crucial for building effective forms.

Controlled Components

Controlled components have their value controlled by React state:

function ControlledForm() {
  const [name, setName] = useState('');
  
  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Submitted name: ${name}`);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}

Uncontrolled Components

Uncontrolled components maintain their own internal state:

function UncontrolledForm() {
  const inputRef = useRef(null);
  
  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Submitted name: ${inputRef.current.value}`);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <label>
        Name:
        <input
          type="text"
          ref={inputRef}
          defaultValue=""
        />
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}

When to use each:

  • Use controlled components for most forms (better validation, control)
  • Use uncontrolled components for simple cases or when integrating with non-React libraries

Building Basic Forms

Simple Input Form

function SimpleForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: ''
  });
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Form data:', formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>First Name:</label>
        <input
          type="text"
          name="firstName"
          value={formData.firstName}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label>Last Name:</label>
        <input
          type="text"
          name="lastName"
          value={formData.lastName}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
        />
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Different Input Types

function VariousInputs() {
  const [formData, setFormData] = useState({
    text: '',
    password: '',
    number: '',
    date: '',
    select: '',
    checkbox: false,
    radio: '',
    textarea: ''
  });
  
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value
    }));
  };
  
  return (
    <form>
      <div>
        <label>Text Input:</label>
        <input
          type="text"
          name="text"
          value={formData.text}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label>Password:</label>
        <input
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label>Number:</label>
        <input
          type="number"
          name="number"
          value={formData.number}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label>Date:</label>
        <input
          type="date"
          name="date"
          value={formData.date}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label>Select:</label>
        <select
          name="select"
          value={formData.select}
          onChange={handleChange}
        >
          <option value="">Choose an option</option>
          <option value="option1">Option 1</option>
          <option value="option2">Option 2</option>
          <option value="option3">Option 3</option>
        </select>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            name="checkbox"
            checked={formData.checkbox}
            onChange={handleChange}
          />
          I agree to the terms
        </label>
      </div>
      
      <div>
        <label>Radio Options:</label>
        <label>
          <input
            type="radio"
            name="radio"
            value="option1"
            checked={formData.radio === 'option1'}
            onChange={handleChange}
          />
          Option 1
        </label>
        <label>
          <input
            type="radio"
            name="radio"
            value="option2"
            checked={formData.radio === 'option2'}
            onChange={handleChange}
          />
          Option 2
        </label>
      </div>
      
      <div>
        <label>Textarea:</label>
        <textarea
          name="textarea"
          value={formData.textarea}
          onChange={handleChange}
          rows="4"
          cols="50"
        />
      </div>
    </form>
  );
}

Form Validation

Basic Validation

function ValidatedForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    age: ''
  });
  
  const [errors, setErrors] = useState({});
  
  const validateForm = () => {
    const newErrors = {};
    
    if (!formData.name.trim()) {
      newErrors.name = 'Name is required';
    } else if (formData.name.length < 2) {
      newErrors.name = 'Name must be at least 2 characters';
    }
    
    if (!formData.email.trim()) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = 'Email is invalid';
    }
    
    if (!formData.age) {
      newErrors.age = 'Age is required';
    } else if (isNaN(formData.age) || formData.age < 18) {
      newErrors.age = 'You must be at least 18 years old';
    }
    
    return newErrors;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    const newErrors = validateForm();
    
    if (Object.keys(newErrors).length === 0) {
      console.log('Form submitted:', formData);
      setErrors({});
    } else {
      setErrors(newErrors);
    }
  };
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
    
    // Clear error for this field when user starts typing
    if (errors[name]) {
      setErrors(prev => ({
        ...prev,
        [name]: ''
      }));
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name:</label>
        <input
          type="text"
          name="name"
          value={formData.name}
          onChange={handleChange}
          className={errors.name ? 'error' : ''}
        />
        {errors.name && <span className="error-message">{errors.name}</span>}
      </div>
      
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          className={errors.email ? 'error' : ''}
        />
        {errors.email && <span className="error-message">{errors.email}</span>}
      </div>
      
      <div>
        <label>Age:</label>
        <input
          type="number"
          name="age"
          value={formData.age}
          onChange={handleChange}
          className={errors.age ? 'error' : ''}
        />
        {errors.age && <span className="error-message">{errors.age}</span>}
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Real-time Validation

function RealTimeValidation() {
  const [formData, setFormData] = useState({
    username: '',
    password: ''
  });
  
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  
  const validateField = (name, value) => {
    const newErrors = { ...errors };
    
    switch (name) {
      case 'username':
        if (!value) {
          newErrors.username = 'Username is required';
        } else if (value.length < 3) {
          newErrors.username = 'Username must be at least 3 characters';
        } else if (!/^[a-zA-Z0-9_]+$/.test(value)) {
          newErrors.username = 'Username can only contain letters, numbers, and underscores';
        } else {
          delete newErrors.username;
        }
        break;
        
      case 'password':
        if (!value) {
          newErrors.password = 'Password is required';
        } else if (value.length < 8) {
          newErrors.password = 'Password must be at least 8 characters';
        } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
          newErrors.password = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
        } else {
          delete newErrors.password;
        }
        break;
        
      default:
        break;
    }
    
    return newErrors;
  };
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    
    if (touched[name]) {
      setErrors(validateField(name, value));
    }
  };
  
  const handleBlur = (e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(validateField(name, formData[name]));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    
    // Validate all fields
    const allErrors = Object.keys(formData).reduce((acc, field) => {
      return validateField(field, formData[field]);
    }, {});
    
    setErrors(allErrors);
    
    if (Object.keys(allErrors).length === 0) {
      console.log('Form submitted:', formData);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Username:</label>
        <input
          type="text"
          name="username"
          value={formData.username}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.username && errors.username ? 'error' : ''}
        />
        {touched.username && errors.username && (
          <span className="error-message">{errors.username}</span>
        )}
      </div>
      
      <div>
        <label>Password:</label>
        <input
          type="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.password && errors.password ? 'error' : ''}
        />
        {touched.password && errors.password && (
          <span className="error-message">{errors.password}</span>
        )}
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Advanced Form Patterns

Multi-step Form

function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({
    personal: {
      firstName: '',
      lastName: '',
      email: ''
    },
    address: {
      street: '',
      city: '',
      zipCode: ''
    },
    preferences: {
      newsletter: false,
      notifications: true
    }
  });
  
  const nextStep = () => setStep(step + 1);
  const prevStep = () => setStep(step - 1);
  
  const handleChange = (section, field, value) => {
    setFormData(prev => ({
      ...prev,
      [section]: {
        ...prev[section],
        [field]: value
      }
    }));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Final form data:', formData);
  };
  
  const renderStep = () => {
    switch (step) {
      case 1:
        return (
          <PersonalInfoStep
            data={formData.personal}
            onChange={(field, value) => handleChange('personal', field, value)}
          />
        );
      case 2:
        return (
          <AddressStep
            data={formData.address}
            onChange={(field, value) => handleChange('address', field, value)}
          />
        );
      case 3:
        return (
          <PreferencesStep
            data={formData.preferences}
            onChange={(field, value) => handleChange('preferences', field, value)}
          />
        );
      default:
        return null;
    }
  };
  
  return (
    <div>
      <h2>Step {step} of 3</h2>
      
      <form onSubmit={step === 3 ? handleSubmit : (e) => { e.preventDefault(); nextStep(); }}>
        {renderStep()}
        
        <div className="step-controls">
          {step > 1 && (
            <button type="button" onClick={prevStep}>
              Previous
            </button>
          )}
          
          <button type="submit">
            {step === 3 ? 'Submit' : 'Next'}
          </button>
        </div>
      </form>
    </div>
  );
}

function PersonalInfoStep({ data, onChange }) {
  return (
    <div>
      <h3>Personal Information</h3>
      
      <div>
        <label>First Name:</label>
        <input
          type="text"
          value={data.firstName}
          onChange={(e) => onChange('firstName', e.target.value)}
        />
      </div>
      
      <div>
        <label>Last Name:</label>
        <input
          type="text"
          value={data.lastName}
          onChange={(e) => onChange('lastName', e.target.value)}
        />
      </div>
      
      <div>
        <label>Email:</label>
        <input
          type="email"
          value={data.email}
          onChange={(e) => onChange('email', e.target.value)}
        />
      </div>
    </div>
  );
}

function AddressStep({ data, onChange }) {
  return (
    <div>
      <h3>Address Information</h3>
      
      <div>
        <label>Street:</label>
        <input
          type="text"
          value={data.street}
          onChange={(e) => onChange('street', e.target.value)}
        />
      </div>
      
      <div>
        <label>City:</label>
        <input
          type="text"
          value={data.city}
          onChange={(e) => onChange('city', e.target.value)}
        />
      </div>
      
      <div>
        <label>Zip Code:</label>
        <input
          type="text"
          value={data.zipCode}
          onChange={(e) => onChange('zipCode', e.target.value)}
        />
      </div>
    </div>
  );
}

function PreferencesStep({ data, onChange }) {
  return (
    <div>
      <h3>Preferences</h3>
      
      <div>
        <label>
          <input
            type="checkbox"
            checked={data.newsletter}
            onChange={(e) => onChange('newsletter', e.target.checked)}
          />
          Subscribe to newsletter
        </label>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            checked={data.notifications}
            onChange={(e) => onChange('notifications', e.target.checked)}
          />
          Enable email notifications
        </label>
      </div>
    </div>
  );
}

Dynamic Form Fields

function DynamicForm() {
  const [fields, setFields] = useState([
    { id: 1, name: '', type: 'text' }
  ]);
  
  const addField = () => {
    const newField = {
      id: Date.now(),
      name: '',
      type: 'text'
    };
    setFields([...fields, newField]);
  };
  
  const removeField = (id) => {
    setFields(fields.filter(field => field.id !== id));
  };
  
  const updateField = (id, property, value) => {
    setFields(fields.map(field =>
      field.id === id ? { ...field, [property]: value } : field
    ));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Dynamic form data:', fields);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>Dynamic Form</h2>
      
      {fields.map((field, index) => (
        <div key={field.id} className="dynamic-field">
          <span>Field {index + 1}</span>
          
          <input
            type="text"
            placeholder="Field name"
            value={field.name}
            onChange={(e) => updateField(field.id, 'name', e.target.value)}
          />
          
          <select
            value={field.type}
            onChange={(e) => updateField(field.id, 'type', e.target.value)}
          >
            <option value="text">Text</option>
            <option value="number">Number</option>
            <option value="email">Email</option>
            <option value="password">Password</option>
          </select>
          
          <button
            type="button"
            onClick={() => removeField(field.id)}
            disabled={fields.length === 1}
          >
            Remove
          </button>
        </div>
      ))}
      
      <button type="button" onClick={addField}>
        Add Field
      </button>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Custom Form Hooks

useForm Hook

function useForm(initialValues, validationSchema) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  const setValue = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
  }, []);
  
  const handleChange = useCallback((e) => {
    const { name, value, type, checked } = e.target;
    const fieldValue = type === 'checkbox' ? checked : value;
    
    setValues(prev => ({ ...prev, [name]: fieldValue }));
    
    if (touched[name] && validationSchema[name]) {
      const error = validationSchema[name](fieldValue);
      setErrors(prev => ({
        ...prev,
        [name]: error
      }));
    }
  }, [touched, validationSchema]);
  
  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    
    if (validationSchema[name]) {
      const error = validationSchema[name](values[name]);
      setErrors(prev => ({
        ...prev,
        [name]: error
      }));
    }
  }, [values, validationSchema]);
  
  const validateForm = useCallback(() => {
    const newErrors = {};
    
    Object.keys(validationSchema).forEach(field => {
      const error = validationSchema[field](values[field]);
      if (error) {
        newErrors[field] = error;
      }
    });
    
    setErrors(newErrors);
    setTouched(Object.keys(values).reduce((acc, field) => {
      acc[field] = true;
      return acc;
    }, {}));
    
    return Object.keys(newErrors).length === 0;
  }, [values, validationSchema]);
  
  const handleSubmit = useCallback(async (onSubmit) => {
    if (validateForm()) {
      setIsSubmitting(true);
      try {
        await onSubmit(values);
      } catch (error) {
        console.error('Form submission error:', error);
      } finally {
        setIsSubmitting(false);
      }
    }
  }, [values, validateForm]);
  
  const resetForm = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);
  
  return {
    values,
    errors,
    touched,
    isSubmitting,
    setValue,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm,
    validateForm
  };
}

// Usage example
function ContactForm() {
  const validationSchema = {
    name: (value) => {
      if (!value.trim()) return 'Name is required';
      if (value.length < 2) return 'Name must be at least 2 characters';
      return '';
    },
    email: (value) => {
      if (!value.trim()) return 'Email is required';
      if (!/\S+@\S+\.\S+/.test(value)) return 'Email is invalid';
      return '';
    },
    message: (value) => {
      if (!value.trim()) return 'Message is required';
      if (value.length < 10) return 'Message must be at least 10 characters';
      return '';
    }
  };
  
  const {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    resetForm
  } = useForm(
    { name: '', email: '', message: '' },
    validationSchema
  );
  
  const onSubmit = async (formData) => {
    console.log('Submitting form:', formData);
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    alert('Form submitted successfully!');
    resetForm();
  };
  
  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSubmit(onSubmit); }}>
      <div>
        <label>Name:</label>
        <input
          type="text"
          name="name"
          value={values.name}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.name && errors.name ? 'error' : ''}
        />
        {touched.name && errors.name && (
          <span className="error-message">{errors.name}</span>
        )}
      </div>
      
      <div>
        <label>Email:</label>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={touched.email && errors.email ? 'error' : ''}
        />
        {touched.email && errors.email && (
          <span className="error-message">{errors.email}</span>
        )}
      </div>
      
      <div>
        <label>Message:</label>
        <textarea
          name="message"
          value={values.message}
          onChange={handleChange}
          onBlur={handleBlur}
          rows="4"
          className={touched.message && errors.message ? 'error' : ''}
        />
        {touched.message && errors.message && (
          <span className="error-message">{errors.message}</span>
        )}
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Form Libraries

While you can build forms with vanilla React, popular libraries can simplify complex form handling:

React Hook Form Example

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

function ReactHookFormExample() {
  const { control, handleSubmit, formState: { errors } } = useForm();
  
  const onSubmit = (data) => {
    console.log('Form data:', data);
  };
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Name:</label>
        <Controller
          name="name"
          control={control}
          rules={{ required: 'Name is required' }}
          render={({ field }) => (
            <input
              {...field}
              type="text"
              className={errors.name ? 'error' : ''}
            />
          )}
        />
        {errors.name && (
          <span className="error-message">{errors.name.message}</span>
        )}
      </div>
      
      <div>
        <label>Email:</label>
        <Controller
          name="email"
          control={control}
          rules={{
            required: 'Email is required',
            pattern: {
              value: /\S+@\S+\.\S+/,
              message: 'Email is invalid'
            }
          }}
          render={({ field }) => (
            <input
              {...field}
              type="email"
              className={errors.email ? 'error' : ''}
            />
          )}
        />
        {errors.email && (
          <span className="error-message">{errors.email.message}</span>
        )}
      </div>
      
      <button type="submit">Submit</button>
    </form>
  );
}

Best Practices

1. Accessibility

function AccessibleForm() {
  const [email, setEmail] = useState('');
  
  return (
    <form>
      <div>
        <label htmlFor="email-input">
          Email Address
          <span className="required" aria-label="required">*</span>
        </label>
        <input
          id="email-input"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          aria-describedby="email-help email-error"
          aria-invalid={email && !/\S+@\S+\.\S+/.test(email)}
          required
        />
        <div id="email-help" className="help-text">
          Enter your email address
        </div>
        {email && !/\S+@\S+\.\S+/.test(email) && (
          <div id="email-error" className="error-message" role="alert">
            Please enter a valid email address
          </div>
        )}
      </div>
    </form>
  );
}

2. Performance Optimization

function OptimizedForm() {
  const [formData, setFormData] = useState({
    field1: '',
    field2: '',
    field3: ''
  });
  
  // Use useCallback for event handlers
  const handleChange = useCallback((e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  }, []);
  
  // Memoize expensive computations
  const computedValue = useMemo(() => {
    return formData.field1.toUpperCase();
  }, [formData.field1]);
  
  return (
    <form>
      <input
        name="field1"
        value={formData.field1}
        onChange={handleChange}
      />
      <input
        name="field2"
        value={formData.field2}
        onChange={handleChange}
      />
      <input
        name="field3"
        value={formData.field3}
        onChange={handleChange}
      />
      
      <div>Computed: {computedValue}</div>
    </form>
  );
}

Summary

Building forms in React involves several key concepts:

  • Use controlled components for better form management
  • Implement validation both on submit and in real-time
  • Handle different input types appropriately
  • Consider accessibility for all users
  • Optimize performance with proper hooks and memoization
  • Use form libraries for complex scenarios
  • Create reusable form patterns and custom hooks

Mastering React forms will enable you to build sophisticated user interfaces with excellent user experience.


External Resources:

Related Tutorials:

Last updated on