References

Form State Management

Controlled vs uncontrolled components, validation, multi-step forms, and file uploads

Core Patterns

  • When to Read This
  • Controlled vs Uncontrolled
  • Basic Form Patterns
  • Validation Patterns

When to Read This

  • Building forms with React
  • Choosing between controlled and uncontrolled inputs
  • Implementing form validation
  • Handling file uploads
  • Creating multi-step wizards

Controlled vs Uncontrolled

Decision Matrix

FeatureControlledUncontrolledRecommendation
State locationReact stateDOMControlled for most cases
Value accessImmediateVia refControlled for validation
ValidationReal-timeOn submitControlled for UX
Conditional logicEasyDifficultControlled
PerformanceSlight overheadFasterUncontrolled for large forms
IntegrationSimpleComplexControlled

✅ Controlled Components

React state is the single source of truth.

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

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

Pros: Immediate value access, real-time validation, easy conditional logic, easy reset/transform.

Cons: Slightly more code, re-renders on every keystroke.

✅ Uncontrolled Components

DOM is the source of truth, accessed via ref.

function UncontrolledInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = () => {
    console.log(inputRef.current?.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="Initial" />
      <button type="submit">Submit</button>
    </form>
  );
}

Pros: Less code, better performance (no re-renders), good for large forms with minimal interaction.

Cons: No value access without ref, harder to validate in real-time, complex conditional logic.


Basic Form Patterns

✅ Simple Controlled Form

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

function LoginForm() {
  const [formData, setFormData] = useState<FormData>({
    email: '',
    password: '',
  });
  const [errors, setErrors] = useState<Partial<FormData>>({});

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

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

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

    const newErrors: Partial<FormData> = {};
    if (!formData.email) newErrors.email = 'Email is required';
    if (!formData.password) newErrors.password = 'Password is required';

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    submitLogin(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>

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

✅ Form with useReducer

interface State {
  values: FormData;
  errors: Partial<FormData>;
  touched: Partial<Record<keyof FormData, boolean>>;
  isSubmitting: boolean;
}

type Action =
  | { type: 'CHANGE'; field: keyof FormData; value: string }
  | { type: 'BLUR'; field: keyof FormData }
  | { type: 'SET_ERRORS'; errors: Partial<FormData> }
  | { type: 'SUBMIT_START' }
  | { type: 'SUBMIT_SUCCESS' }
  | { type: 'SUBMIT_ERROR' }
  | { type: 'RESET' };

function formReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'CHANGE':
      return {
        ...state,
        values: { ...state.values, [action.field]: action.value },
        errors: { ...state.errors, [action.field]: undefined },
      };
    case 'BLUR':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
      };
    case 'SET_ERRORS':
      return { ...state, errors: action.errors };
    case 'SUBMIT_START':
      return { ...state, isSubmitting: true };
    case 'SUBMIT_SUCCESS':
      return { ...state, isSubmitting: false };
    case 'SUBMIT_ERROR':
      return { ...state, isSubmitting: false };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}

const initialState: State = {
  values: { email: '', password: '' },
  errors: {},
  touched: {},
  isSubmitting: false,
};

function LoginForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch({
      type: 'CHANGE',
      field: e.target.name as keyof FormData,
      value: e.target.value,
    });
  };

  const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    dispatch({ type: 'BLUR', field: e.target.name as keyof FormData });
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    dispatch({ type: 'SUBMIT_START' });

    try {
      await submitLogin(state.values);
      dispatch({ type: 'SUBMIT_SUCCESS' });
    } catch (error) {
      dispatch({ type: 'SUBMIT_ERROR' });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Submitting...' : 'Login'}
      </button>
    </form>
  );
}

Validation Patterns

✅ Validation on Blur

function EmailInput() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  const [touched, setTouched] = useState(false);

  const validateEmail = (value: string) => {
    if (!value) return 'Email is required';
    if (!/\S+@\S+\.\S+/.test(value)) return 'Email is invalid';
    return '';
  };

  const handleBlur = () => {
    setTouched(true);
    setError(validateEmail(email));
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setEmail(value);

    // Only validate if already touched
    if (touched) {
      setError(validateEmail(value));
    }
  };

  return (
    <div>
      <input
        type="email"
        value={email}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      {touched && error && <span className="error">{error}</span>}
    </div>
  );
}

✅ Async Validation

function UsernameInput() {
  const [username, setUsername] = useState('');
  const [error, setError] = useState('');
  const [checking, setChecking] = useState(false);

  useEffect(() => {
    if (!username) return;

    const controller = new AbortController();
    setChecking(true);

    const checkUsername = async () => {
      try {
        await new Promise(resolve => setTimeout(resolve, 500)); // Debounce
        const available = await api.checkUsername(username, {
          signal: controller.signal,
        });

        if (!available) {
          setError('Username is already taken');
        } else {
          setError('');
        }
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError('Failed to check username');
        }
      } finally {
        setChecking(false);
      }
    };

    checkUsername();

    return () => controller.abort();
  }, [username]);

  return (
    <div>
      <input
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      {checking && <span>Checking...</span>}
      {error && <span className="error">{error}</span>}
    </div>
  );
}

✅ Cross-Field Validation

function PasswordForm() {
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');
  const [errors, setErrors] = useState({ password: '', confirmPassword: '' });

  useEffect(() => {
    if (password && confirmPassword && password !== confirmPassword) {
      setErrors(prev => ({
        ...prev,
        confirmPassword: 'Passwords do not match',
      }));
    } else {
      setErrors(prev => ({ ...prev, confirmPassword: '' }));
    }
  }, [password, confirmPassword]);

  return (
    <form>
      <div>
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="Password"
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>

      <div>
        <input
          type="password"
          value={confirmPassword}
          onChange={(e) => setConfirmPassword(e.target.value)}
          placeholder="Confirm Password"
        />
        {errors.confirmPassword && <span className="error">{errors.confirmPassword}</span>}
      </div>
    </form>
  );
}

File Uploads

✅ Single File Upload

function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [preview, setPreview] = useState<string>('');
  const [uploading, setUploading] = useState(false);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFile = e.target.files?.[0];
    if (!selectedFile) return;

    setFile(selectedFile);

    if (selectedFile.type.startsWith('image/')) {
      const reader = new FileReader();
      reader.onloadend = () => {
        setPreview(reader.result as string);
      };
      reader.readAsDataURL(selectedFile);
    }
  };

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);
    const formData = new FormData();
    formData.append('file', file);

    try {
      await api.uploadFile(formData);
      alert('Upload successful');
    } catch (error) {
      alert('Upload failed');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input type="file" onChange={handleFileChange} accept="image/*" />
      {preview && <img src={preview} alt="Preview" width={200} />}
      <button onClick={handleUpload} disabled={!file || uploading}>
        {uploading ? 'Uploading...' : 'Upload'}
      </button>
    </div>
  );
}

✅ Multiple File Upload with Progress

function MultiFileUpload() {
  const [files, setFiles] = useState<File[]>([]);
  const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = Array.from(e.target.files || []);
    setFiles(prev => [...prev, ...selectedFiles]);
  };

  const handleUpload = async () => {
    for (const file of files) {
      const formData = new FormData();
      formData.append('file', file);

      try {
        await api.uploadFile(formData, {
          onUploadProgress: (progressEvent) => {
            const progress = Math.round(
              (progressEvent.loaded * 100) / progressEvent.total
            );
            setUploadProgress(prev => ({ ...prev, [file.name]: progress }));
          },
        });
      } catch (error) {
        console.error(`Failed to upload ${file.name}`);
      }
    }
  };

  return (
    <div>
      <input type="file" multiple onChange={handleFileChange} />
      <ul>
        {files.map((file) => (
          <li key={file.name}>
            {file.name} - {uploadProgress[file.name] || 0}%
          </li>
        ))}
      </ul>
      <button onClick={handleUpload}>Upload All</button>
    </div>
  );
}

Multi-Step Forms

✅ Wizard Pattern

function MultiStepForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState({
    // Step 1
    name: '',
    email: '',
    // Step 2
    address: '',
    city: '',
    // Step 3
    cardNumber: '',
    expiry: '',
  });

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

  const nextStep = () => setStep(prev => prev + 1);
  const prevStep = () => setStep(prev => prev - 1);

  const handleSubmit = async () => {
    await api.submitForm(formData);
  };

  return (
    <div>
      <div className="progress">
        Step {step} of 3
      </div>

      {step === 1 && (
        <div>
          <h2>Personal Information</h2>
          <input
            value={formData.name}
            onChange={(e) => updateField('name', e.target.value)}
            placeholder="Name"
          />
          <input
            value={formData.email}
            onChange={(e) => updateField('email', e.target.value)}
            placeholder="Email"
          />
          <button onClick={nextStep}>Next</button>
        </div>
      )}

      {step === 2 && (
        <div>
          <h2>Address</h2>
          <input
            value={formData.address}
            onChange={(e) => updateField('address', e.target.value)}
            placeholder="Address"
          />
          <input
            value={formData.city}
            onChange={(e) => updateField('city', e.target.value)}
            placeholder="City"
          />
          <button onClick={prevStep}>Back</button>
          <button onClick={nextStep}>Next</button>
        </div>
      )}

      {step === 3 && (
        <div>
          <h2>Payment</h2>
          <input
            value={formData.cardNumber}
            onChange={(e) => updateField('cardNumber', e.target.value)}
            placeholder="Card Number"
          />
          <input
            value={formData.expiry}
            onChange={(e) => updateField('expiry', e.target.value)}
            placeholder="Expiry"
          />
          <button onClick={prevStep}>Back</button>
          <button onClick={handleSubmit}>Submit</button>
        </div>
      )}
    </div>
  );
}

Dynamic Fields

✅ Add/Remove Fields

interface Item {
  id: string;
  name: string;
  quantity: number;
}

function DynamicForm() {
  const [items, setItems] = useState<Item[]>([
    { id: '1', name: '', quantity: 1 },
  ]);

  const addItem = () => {
    setItems(prev => [
      ...prev,
      { id: Date.now().toString(), name: '', quantity: 1 },
    ]);
  };

  const removeItem = (id: string) => {
    setItems(prev => prev.filter(item => item.id !== id));
  };

  const updateItem = (id: string, field: keyof Item, value: string | number) => {
    setItems(prev =>
      prev.map(item =>
        item.id === id ? { ...item, [field]: value } : item
      )
    );
  };

  return (
    <form>
      {items.map((item, index) => (
        <div key={item.id}>
          <input
            value={item.name}
            onChange={(e) => updateItem(item.id, 'name', e.target.value)}
            placeholder="Item name"
          />
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value))}
            placeholder="Quantity"
          />
          {items.length > 1 && (
            <button type="button" onClick={() => removeItem(item.id)}>
              Remove
            </button>
          )}
        </div>
      ))}
      <button type="button" onClick={addItem}>
        Add Item
      </button>
    </form>
  );
}

References