React Forms: Controlled Components and Validation

In React, form inputs can be managed in two ways: controlled or uncontrolled. An uncontrolled component stores its own state internally in the DOM, just like traditional HTML forms. A controlled...

Key Insights

  • Controlled components maintain form state in React rather than the DOM, providing a single source of truth that makes validation and data manipulation straightforward
  • Start with basic controlled inputs and native validation, only reaching for form libraries like React Hook Form when managing complex forms with numerous fields
  • Implement validation strategically—validate on blur for better UX, on submit for critical checks, and debounce real-time validation to avoid performance issues

Introduction to Controlled Components

In React, form inputs can be managed in two ways: controlled or uncontrolled. An uncontrolled component stores its own state internally in the DOM, just like traditional HTML forms. A controlled component, however, has its value controlled by React state.

The controlled component pattern is React’s recommended approach because it establishes React as the single source of truth. Every state mutation has an associated handler function, making it trivial to modify or validate user input. You can enforce formatting, prevent invalid characters, or transform values on the fly—something that’s awkward with uncontrolled components.

Here’s the simplest controlled input:

import { useState } from 'react';

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

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

The input’s value is bound to state, and every keystroke triggers onChange, updating that state. This creates a controlled feedback loop where React always knows the current value.

Building a Basic Controlled Form

Real forms have multiple fields. Let’s build a registration form with name, email, and password inputs:

import { useState } from 'react';

function RegistrationForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: ''
  });

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

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('Form submitted:', formData);
    // API call would go here
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name:</label>
        <input
          id="name"
          name="name"
          type="text"
          value={formData.name}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
        />
      </div>
      
      <div>
        <label htmlFor="password">Password:</label>
        <input
          id="password"
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
        />
      </div>
      
      <button type="submit">Register</button>
    </form>
  );
}

We use a single state object for all fields and a generic handleChange function that uses the input’s name attribute to update the correct field. This scales better than creating separate state variables and handlers for each input.

Form Validation Strategies

Client-side validation improves UX by providing immediate feedback. You have several timing options:

  • On change: Validate as the user types (can be annoying)
  • On blur: Validate when the user leaves the field (better UX)
  • On submit: Validate everything when they submit (minimum viable)

I recommend combining on-blur validation with submit validation. Here’s an implementation:

import { useState } from 'react';

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

  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validateEmail = (email) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!email) return 'Email is required';
    if (!emailRegex.test(email)) return 'Invalid email format';
    return '';
  };

  const validatePassword = (password) => {
    if (!password) return 'Password is required';
    if (password.length < 8) return 'Password must be at least 8 characters';
    if (!/[A-Z]/.test(password)) return 'Password must contain an uppercase letter';
    if (!/[0-9]/.test(password)) return 'Password must contain a number';
    return '';
  };

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

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

    let error = '';
    if (name === 'email') error = validateEmail(value);
    if (name === 'password') error = validatePassword(value);

    setErrors(prev => ({ ...prev, [name]: error }));
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    const emailError = validateEmail(formData.email);
    const passwordError = validatePassword(formData.password);

    if (emailError || passwordError) {
      setErrors({ email: emailError, password: passwordError });
      setTouched({ email: true, password: true });
      return;
    }

    console.log('Valid form submitted:', formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email:</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={touched.email && errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
          style={{ borderColor: touched.email && errors.email ? 'red' : '' }}
        />
        {touched.email && errors.email && (
          <span id="email-error" role="alert" style={{ color: 'red' }}>
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password:</label>
        <input
          id="password"
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={touched.password && errors.password ? 'true' : 'false'}
          aria-describedby={errors.password ? 'password-error' : undefined}
          style={{ borderColor: touched.password && errors.password ? 'red' : '' }}
        />
        {touched.password && errors.password && (
          <span id="password-error" role="alert" style={{ color: 'red' }}>
            {errors.password}
          </span>
        )}
      </div>

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

The touched state prevents showing errors before the user interacts with a field. We only display errors after they’ve visited the field or attempted submission.

Advanced Patterns: Custom Hooks

Once you’ve built a few forms, you’ll notice repetitive patterns. Extract this logic into a custom hook:

import { useState } from 'react';

function useForm(initialValues, validationRules) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validate = (name, value) => {
    if (!validationRules[name]) return '';
    
    for (const rule of validationRules[name]) {
      const error = rule(value);
      if (error) return error;
    }
    return '';
  };

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
    
    if (touched[name]) {
      const error = validate(name, value);
      setErrors(prev => ({ ...prev, [name]: error }));
    }
  };

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

  const handleSubmit = (onSubmit) => (e) => {
    e.preventDefault();
    
    const newErrors = {};
    const newTouched = {};
    
    Object.keys(values).forEach(name => {
      newTouched[name] = true;
      const error = validate(name, values[name]);
      if (error) newErrors[name] = error;
    });

    setTouched(newTouched);
    setErrors(newErrors);

    if (Object.keys(newErrors).length === 0) {
      onSubmit(values);
    }
  };

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit
  };
}

// Usage
function MyForm() {
  const { values, errors, touched, handleChange, handleBlur, handleSubmit } = useForm(
    { email: '', password: '' },
    {
      email: [
        (value) => !value ? 'Email is required' : '',
        (value) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email' : ''
      ],
      password: [
        (value) => !value ? 'Password is required' : '',
        (value) => value.length < 8 ? 'Must be 8+ characters' : ''
      ]
    }
  );

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        name="email"
        value={values.email}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      {touched.email && errors.email && <span>{errors.email}</span>}
      
      <input
        name="password"
        type="password"
        value={values.password}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      {touched.password && errors.password && <span>{errors.password}</span>}
      
      <button type="submit">Submit</button>
    </form>
  );
}

This hook eliminates boilerplate and makes validation rules declarative and reusable.

Working with Form Libraries

For complex forms with dozens of fields, conditional logic, and intricate validation, consider React Hook Form:

import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';

const schema = yup.object({
  email: yup.string().email('Invalid email').required('Email is required'),
  password: yup.string()
    .min(8, 'Must be 8+ characters')
    .matches(/[A-Z]/, 'Must contain uppercase')
    .matches(/[0-9]/, 'Must contain number')
    .required('Password is required')
}).required();

function LibraryForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: yupResolver(schema)
  });

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      
      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}
      
      <button type="submit">Submit</button>
    </form>
  );
}

React Hook Form minimizes re-renders by using uncontrolled components internally while providing a controlled-like API. It’s performant and pairs excellently with schema validation libraries like Yup or Zod.

Use libraries when you have: multiple forms in your app, complex validation requirements, dynamic field arrays, or when you need features like field-level subscriptions. For simple forms, custom implementations are often clearer and more maintainable.

Best Practices and Performance

Validating on every keystroke can hurt performance and UX. Debounce expensive validations:

import { useState, useCallback } from 'react';

function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

function DebouncedValidation() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const validateEmail = useCallback(
    debounce((value) => {
      // Expensive validation or API call
      const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
      setError(isValid ? '' : 'Invalid email');
    }, 500),
    []
  );

  const handleChange = (e) => {
    const value = e.target.value;
    setEmail(value);
    validateEmail(value);
  };

  return (
    <div>
      <input value={email} onChange={handleChange} />
      {error && <span>{error}</span>}
    </div>
  );
}

Always include proper ARIA attributes for accessibility. Use aria-invalid, aria-describedby, and role="alert" for error messages. This ensures screen readers announce validation errors appropriately.

For performance, avoid creating new objects or functions in render. Use useCallback for event handlers and useMemo for computed values when dealing with large forms.

Conclusion

Controlled components give you complete control over form behavior in React. Start with basic controlled inputs using useState, add validation that triggers on blur and submit, and extract common patterns into custom hooks as your needs grow.

For simple forms, native implementation is perfectly adequate. Reach for libraries like React Hook Form when complexity justifies the dependency. The key is understanding the fundamentals—once you grasp controlled components and validation patterns, you can make informed decisions about when to abstract or adopt third-party solutions.

Build your forms with accessibility in mind from the start, debounce expensive operations, and always validate on the server regardless of client-side checks. Your users will appreciate the immediate feedback, and your codebase will remain maintainable as requirements evolve.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.