Skip to main content

Forms & Controlled Components

Forms are fundamental to web applications. React provides two approaches: Controlled Components (React manages state) and Uncontrolled Components (DOM manages state).

Controlled vs Uncontrolled

AspectControlledUncontrolled
State locationReact stateDOM
Access to valueuseState variableuseRef
ValidationOn every keystrokeOn submit
Use caseMost formsFile inputs, quick prototypes
Controlled Component:
┌─────────────────────────────────────────────────────────┐
│  User types →  onChange  →  setState  →  re-render     │
│                    ↓                                    │
│              value={state}                              │
│                    ↓                                    │
│              Input displays state value                 │
└─────────────────────────────────────────────────────────┘

Uncontrolled Component:
┌─────────────────────────────────────────────────────────┐
│  User types →  DOM handles value                        │
│                    ↓                                    │
│  On submit  →  ref.current.value                       │
└─────────────────────────────────────────────────────────┘

Controlled Components

With controlled components, React state is the “single source of truth” for input values.

Basic Text Input

import { useState } from 'react';

function TextInput() {
  const [value, setValue] = useState('');

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  return (
    <div>
      <input
        type="text"
        value={value}
        onChange={handleChange}
        placeholder="Type something..."
      />
      <p>You typed: {value}</p>
    </div>
  );
}

Complete Form Example

function RegistrationForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: '',
    agreeToTerms: false,
    role: 'user',
    interests: []
  });

  const handleChange = (event) => {
    const { name, value, type, checked } = event.target;
    
    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value
    }));
  };

  const handleInterestChange = (event) => {
    const { value, checked } = event.target;
    
    setFormData(prev => ({
      ...prev,
      interests: checked
        ? [...prev.interests, value]
        : prev.interests.filter(i => i !== value)
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('Form submitted:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Text Input */}
      <div>
        <label htmlFor="username">Username:</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          required
        />
      </div>

      {/* Email Input */}
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          required
        />
      </div>

      {/* Password Inputs */}
      <div>
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          minLength={8}
          required
        />
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password:</label>
        <input
          type="password"
          id="confirmPassword"
          name="confirmPassword"
          value={formData.confirmPassword}
          onChange={handleChange}
          required
        />
      </div>

      {/* Select Dropdown */}
      <div>
        <label htmlFor="role">Role:</label>
        <select
          id="role"
          name="role"
          value={formData.role}
          onChange={handleChange}
        >
          <option value="user">User</option>
          <option value="admin">Admin</option>
          <option value="moderator">Moderator</option>
        </select>
      </div>

      {/* Checkbox Group */}
      <fieldset>
        <legend>Interests:</legend>
        {['Technology', 'Sports', 'Music', 'Art'].map(interest => (
          <label key={interest}>
            <input
              type="checkbox"
              value={interest}
              checked={formData.interests.includes(interest)}
              onChange={handleInterestChange}
            />
            {interest}
          </label>
        ))}
      </fieldset>

      {/* Single Checkbox */}
      <div>
        <label>
          <input
            type="checkbox"
            name="agreeToTerms"
            checked={formData.agreeToTerms}
            onChange={handleChange}
            required
          />
          I agree to the terms and conditions
        </label>
      </div>

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

Form Elements Reference

Textarea

In React, <textarea> uses a value prop instead of children:
// ❌ HTML way
<textarea>Initial text</textarea>

// ✅ React way
<textarea 
  value={text} 
  onChange={(e) => setText(e.target.value)} 
/>

Select

Use value on the <select> element, not selected on options:
// ❌ HTML way
<select>
  <option selected>Apple</option>
  <option>Banana</option>
</select>

// ✅ React way
<select value={fruit} onChange={(e) => setFruit(e.target.value)}>
  <option value="apple">Apple</option>
  <option value="banana">Banana</option>
</select>

Multiple Select

function MultiSelect() {
  const [selected, setSelected] = useState([]);

  const handleChange = (e) => {
    const options = Array.from(e.target.selectedOptions);
    setSelected(options.map(option => option.value));
  };

  return (
    <select multiple value={selected} onChange={handleChange}>
      <option value="react">React</option>
      <option value="vue">Vue</option>
      <option value="angular">Angular</option>
    </select>
  );
}

Radio Buttons

function RadioGroup() {
  const [selected, setSelected] = useState('medium');

  return (
    <fieldset>
      <legend>Size:</legend>
      {['small', 'medium', 'large'].map(size => (
        <label key={size}>
          <input
            type="radio"
            name="size"
            value={size}
            checked={selected === size}
            onChange={(e) => setSelected(e.target.value)}
          />
          {size.charAt(0).toUpperCase() + size.slice(1)}
        </label>
      ))}
    </fieldset>
  );
}

Form Validation

Real-Time Validation

function ValidatedForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validate = (name, value) => {
    switch (name) {
      case 'email':
        if (!value) return 'Email is required';
        if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format';
        return '';
      case 'password':
        if (!value) return 'Password is required';
        if (value.length < 8) return 'Password must be at least 8 characters';
        if (!/[A-Z]/.test(value)) return 'Password must contain uppercase letter';
        if (!/[0-9]/.test(value)) return 'Password must contain a number';
        return '';
      default:
        return '';
    }
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    
    // Validate on change if field has been touched
    if (touched[name]) {
      setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
    }
  };

  const handleBlur = (e) => {
    const { name, value } = e.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    
    // Validate all fields
    const newErrors = {};
    Object.keys(formData).forEach(key => {
      const error = validate(key, formData[key]);
      if (error) newErrors[key] = error;
    });
    
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      setTouched({ email: true, password: true });
      return;
    }
    
    console.log('Form is valid:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={errors.email && touched.email ? 'error' : ''}
        />
        {errors.email && touched.email && (
          <span className="error-message">{errors.email}</span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={errors.password && touched.password ? 'error' : ''}
        />
        {errors.password && touched.password && (
          <span className="error-message">{errors.password}</span>
        )}
      </div>

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

Password Strength Indicator

function PasswordStrength({ password }) {
  const getStrength = (pwd) => {
    let strength = 0;
    if (pwd.length >= 8) strength++;
    if (pwd.length >= 12) strength++;
    if (/[A-Z]/.test(pwd)) strength++;
    if (/[a-z]/.test(pwd)) strength++;
    if (/[0-9]/.test(pwd)) strength++;
    if (/[^A-Za-z0-9]/.test(pwd)) strength++;
    return strength;
  };

  const strength = getStrength(password);
  const labels = ['', 'Weak', 'Fair', 'Good', 'Strong', 'Very Strong', 'Excellent'];
  const colors = ['', '#ef4444', '#f97316', '#eab308', '#22c55e', '#16a34a', '#15803d'];

  return (
    <div className="password-strength">
      <div className="strength-bars">
        {[1, 2, 3, 4, 5, 6].map(level => (
          <div
            key={level}
            className="strength-bar"
            style={{
              backgroundColor: strength >= level ? colors[strength] : '#e5e7eb',
              height: '4px',
              flex: 1,
              marginRight: '2px',
              borderRadius: '2px'
            }}
          />
        ))}
      </div>
      <span style={{ color: colors[strength] }}>{labels[strength]}</span>
    </div>
  );
}

Uncontrolled Components with useRef

For simple forms or when you need direct DOM access:
import { useRef } from 'react';

function UncontrolledForm() {
  const nameRef = useRef();
  const emailRef = useRef();
  const fileRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log({
      name: nameRef.current.value,
      email: emailRef.current.value,
      file: fileRef.current.files[0]
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="John" />
      <input ref={emailRef} type="email" />
      <input ref={fileRef} type="file" />
      <button type="submit">Submit</button>
    </form>
  );
}
File inputs are always uncontrolled in React because their value can only be set by the user, not programmatically.
// File input example
function FileUpload() {
  const [file, setFile] = useState(null);

  const handleChange = (e) => {
    const selectedFile = e.target.files[0];
    setFile(selectedFile);
  };

  return (
    <div>
      <input type="file" onChange={handleChange} accept="image/*" />
      {file && <p>Selected: {file.name}</p>}
    </div>
  );
}

Form Libraries

For complex forms, consider using a form library:

React Hook Form

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

function HookFormExample() {
  const { 
    register, 
    handleSubmit, 
    formState: { errors, isSubmitting } 
  } = useForm();

  const onSubmit = async (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^\S+@\S+$/i,
              message: 'Invalid email format'
            }
          })}
          placeholder="Email"
        />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <input
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters'
            }
          })}
          placeholder="Password"
        />
        {errors.password && <span>{errors.password.message}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}
React Hook Form advantages:
  • Less re-renders (uses refs under the hood)
  • Built-in validation
  • Easy integration with UI libraries
  • Small bundle size

Advanced Patterns

Debounced Input

import { useState, useEffect, useCallback } from 'react';

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      // Make API call
      console.log('Searching for:', debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input
      type="text"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

Form with Dynamic Fields

function DynamicForm() {
  const [fields, setFields] = useState([{ id: 1, value: '' }]);

  const addField = () => {
    setFields([...fields, { id: Date.now(), value: '' }]);
  };

  const removeField = (id) => {
    if (fields.length > 1) {
      setFields(fields.filter(f => f.id !== id));
    }
  };

  const updateField = (id, value) => {
    setFields(fields.map(f => f.id === id ? { ...f, value } : f));
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Values:', fields.map(f => f.value));
  };

  return (
    <form onSubmit={handleSubmit}>
      {fields.map((field, index) => (
        <div key={field.id} className="field-row">
          <input
            value={field.value}
            onChange={(e) => updateField(field.id, e.target.value)}
            placeholder={`Field ${index + 1}`}
          />
          <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>
  );
}

🎯 Practice Exercises

function CreditCardForm() {
  const [card, setCard] = useState({
    number: '',
    name: '',
    expiry: '',
    cvv: ''
  });

  const formatCardNumber = (value) => {
    return value
      .replace(/\D/g, '')
      .slice(0, 16)
      .replace(/(.{4})/g, '$1 ')
      .trim();
  };

  const formatExpiry = (value) => {
    return value
      .replace(/\D/g, '')
      .slice(0, 4)
      .replace(/^(.{2})/, '$1/');
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    
    let formatted = value;
    if (name === 'number') formatted = formatCardNumber(value);
    if (name === 'expiry') formatted = formatExpiry(value);
    if (name === 'cvv') formatted = value.replace(/\D/g, '').slice(0, 3);
    
    setCard(prev => ({ ...prev, [name]: formatted }));
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); console.log(card); }}>
      <input
        name="number"
        value={card.number}
        onChange={handleChange}
        placeholder="1234 5678 9012 3456"
        maxLength={19}
      />
      <input
        name="name"
        value={card.name}
        onChange={(e) => setCard(prev => ({ ...prev, name: e.target.value.toUpperCase() }))}
        placeholder="CARDHOLDER NAME"
      />
      <input
        name="expiry"
        value={card.expiry}
        onChange={handleChange}
        placeholder="MM/YY"
        maxLength={5}
      />
      <input
        name="cvv"
        value={card.cvv}
        onChange={handleChange}
        placeholder="CVV"
        maxLength={3}
        type="password"
      />
      <button type="submit">Pay</button>
    </form>
  );
}
function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [data, setData] = useState({
    // Step 1
    firstName: '',
    lastName: '',
    // Step 2
    email: '',
    phone: '',
    // Step 3
    plan: 'basic',
    addons: []
  });

  const updateData = (field, value) => {
    setData(prev => ({ ...prev, [field]: value }));
  };

  const next = () => setStep(s => Math.min(s + 1, 4));
  const prev = () => setStep(s => Math.max(s - 1, 1));

  return (
    <div className="multi-step">
      <div className="steps-indicator">
        {[1, 2, 3, 4].map(s => (
          <div 
            key={s} 
            className={`step ${step >= s ? 'active' : ''}`}
          >
            {s}
          </div>
        ))}
      </div>

      {step === 1 && (
        <div className="step-content">
          <h2>Personal Info</h2>
          <input
            value={data.firstName}
            onChange={(e) => updateData('firstName', e.target.value)}
            placeholder="First Name"
          />
          <input
            value={data.lastName}
            onChange={(e) => updateData('lastName', e.target.value)}
            placeholder="Last Name"
          />
        </div>
      )}

      {step === 2 && (
        <div className="step-content">
          <h2>Contact Info</h2>
          <input
            type="email"
            value={data.email}
            onChange={(e) => updateData('email', e.target.value)}
            placeholder="Email"
          />
          <input
            type="tel"
            value={data.phone}
            onChange={(e) => updateData('phone', e.target.value)}
            placeholder="Phone"
          />
        </div>
      )}

      {step === 3 && (
        <div className="step-content">
          <h2>Select Plan</h2>
          {['basic', 'pro', 'enterprise'].map(plan => (
            <label key={plan}>
              <input
                type="radio"
                name="plan"
                value={plan}
                checked={data.plan === plan}
                onChange={(e) => updateData('plan', e.target.value)}
              />
              {plan.charAt(0).toUpperCase() + plan.slice(1)}
            </label>
          ))}
        </div>
      )}

      {step === 4 && (
        <div className="step-content">
          <h2>Confirm</h2>
          <pre>{JSON.stringify(data, null, 2)}</pre>
        </div>
      )}

      <div className="buttons">
        {step > 1 && <button onClick={prev}>Back</button>}
        {step < 4 ? (
          <button onClick={next}>Next</button>
        ) : (
          <button onClick={() => alert('Submitted!')}>Submit</button>
        )}
      </div>
    </div>
  );
}

Summary

ConceptDescription
Controlled ComponentsReact state manages form input values
Uncontrolled ComponentsDOM manages values, accessed via refs
value + onChangeRequired pattern for controlled inputs
event.targetAccess input name, value, type, checked
Real-time ValidationValidate on change or blur
touched stateTrack if user has interacted with field
useRefAccess DOM elements directly
React Hook FormPopular library for complex forms
DebouncingDelay processing for search inputs

Next Steps

In the next chapter, you’ll learn about useEffect & Side Effects — fetching data, subscriptions, and more!