ReactForm Validation

Form Validation

Submitting invalid data is one of the most common sources of bugs and poor user experiences. React gives you full control over validation logic — when to validate, what rules to apply, and how to display errors. This page covers building a robust client-side validation system from scratch.

HTML5 Native Validation vs Custom Validation

Browsers provide built-in validation via attributes like required, minLength, type="email", and pattern. This is zero-effort but has significant limitations:

  • Styling is browser-dependent and hard to customize

  • Error messages appear only on submit, not as the user types

  • You cannot cross-validate fields (e.g. "password must match confirm password")

  • Accessibility varies across browsers

  • You cannot programmatically control when validation runs

Custom React validation gives you complete control. Most production applications combine both: use type="email" for the browser keyboard hint on mobile, but rely on custom logic for the actual error display.

When to Validate

Strategy

Trigger

Best For

Submit-time

User clicks Submit

Simple forms, minimal interruption

Blur-time (onBlur)

User leaves a field

Balance between feedback and annoyance

Change-time (onChange)

User types in a field

Real-time feedback (password strength, etc.)

Hybrid

Blur first, then onChange once touched

Production forms — industry standard

The Error State Object

Mirror your form state with an errors object that has the same keys. Each key maps to either an error string or undefined:

JSX
const [form,   setForm]   = useState({ email: '', password: '' })
const [errors, setErrors] = useState({ email: '', password: '' })

// 'errors.email' is either '' (no error) or a message like 'Email is required'
A Validation Function

Keep validation logic in a pure function that takes form values and returns an errors object. This makes it easy to test and reuse:

JSX
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

function validate(values) {
  const errors = {}

  if (!values.firstName.trim()) {
    errors.firstName = 'First name is required.'
  }

  if (!values.email.trim()) {
    errors.email = 'Email is required.'
  } else if (!EMAIL_REGEX.test(values.email)) {
    errors.email = 'Please enter a valid email address.'
  }

  if (!values.password) {
    errors.password = 'Password is required.'
  } else if (values.password.length < 8) {
    errors.password = 'Password must be at least 8 characters.'
  } else if (!/[A-Z]/.test(values.password)) {
    errors.password = 'Password must contain at least one uppercase letter.'
  }

  if (values.confirmPassword !== values.password) {
    errors.confirmPassword = 'Passwords do not match.'
  }

  return errors
}

// A form is valid when the errors object has no keys:
const isValid = Object.keys(validate(form)).length === 0
Submit-Time Validation

JSX
import { useState } from 'react'

function SignupForm() {
  const [form, setForm]     = useState({ email: '', password: '' })
  const [errors, setErrors] = useState({})

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

  function handleSubmit(e) {
    e.preventDefault()
    const validationErrors = validate(form)

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors)
      return   // stop here — do not submit
    }

    // All good — submit to the API
    setErrors({})
    console.log('Submitting', form)
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <input
          name="email"
          type="email"
          value={form.email}
          onChange={handleChange}
          aria-describedby="email-error"
          aria-invalid={!!errors.email}
        />
        {errors.email && (
          <span id="email-error" role="alert" style={{ color: 'red' }}>
            {errors.email}
          </span>
        )}
      </div>

      <div>
        <input
          name="password"
          type="password"
          value={form.password}
          onChange={handleChange}
          aria-describedby="password-error"
          aria-invalid={!!errors.password}
        />
        {errors.password && (
          <span id="password-error" role="alert" style={{ color: 'red' }}>
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">Sign Up</button>
    </form>
  )
}
Note
The `noValidate` attribute on the form disables the browser's native validation pop-ups so your custom errors are the only feedback the user sees.
Hybrid Validation: Blur Then Change

The best UX pattern: show errors only after a field has been touched (blurred at least once), then update errors in real time as the user corrects them. This avoids screaming red errors the moment someone opens the form.

JSX
import { useState } from 'react'

function SignupForm() {
  const [form,    setForm]    = useState({ firstName: '', email: '', password: '' })
  const [errors,  setErrors]  = useState({})
  const [touched, setTouched] = useState({})

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

    // Re-validate this field if it has already been touched
    if (touched[name]) {
      const newErrors = validate({ ...form, [name]: value })
      setErrors(prev => ({
        ...prev,
        [name]: newErrors[name] ?? '',
      }))
    }
  }

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

    // Validate the field the moment the user leaves it
    const newErrors = validate(form)
    setErrors(prev => ({ ...prev, [name]: newErrors[name] ?? '' }))
  }

  function handleSubmit(e) {
    e.preventDefault()
    // Mark all fields as touched so errors show even for untouched fields
    setTouched({ firstName: true, email: true, password: true })

    const validationErrors = validate(form)
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors)
      return
    }
    console.log('Valid! Submitting:', form)
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <FieldGroup
        label="First name"
        name="firstName"
        value={form.firstName}
        error={errors.firstName}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      <FieldGroup
        label="Email"
        name="email"
        type="email"
        value={form.email}
        error={errors.email}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      <FieldGroup
        label="Password"
        name="password"
        type="password"
        value={form.password}
        error={errors.password}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      <button type="submit">Sign Up</button>
    </form>
  )
}

// Reusable field component with accessible error display
function FieldGroup({ label, name, type = 'text', value, error, onChange, onBlur }) {
  const errorId = `${name}-error`
  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        name={name}
        type={type}
        value={value}
        onChange={onChange}
        onBlur={onBlur}
        aria-describedby={error ? errorId : undefined}
        aria-invalid={!!error}
        style={{ borderColor: error ? 'red' : undefined }}
      />
      {error && (
        <span id={errorId} role="alert" style={{ color: 'red', fontSize: '0.875rem' }}>
          {error}
        </span>
      )}
    </div>
  )
}
Accessibility: Linking Errors to Inputs

Screen readers need to know that an error message is associated with an input. Use two attributes together:

  • aria-describedby="error-id" on the input — links to the error element

  • aria-invalid="true" on the input — announces the invalid state

  • role="alert" on the error span — announces the message immediately when it appears

  • id on the error span — must match the aria-describedby value

JSX
<input
  id="email"
  name="email"
  type="email"
  aria-describedby={errors.email ? 'email-error' : undefined}
  aria-invalid={!!errors.email}
/>
{errors.email && (
  <span id="email-error" role="alert">
    {errors.email}
  </span>
)}
Password Strength Indicator

Real-time feedback on password quality is a good use of change-time validation:

JSX
function getPasswordStrength(password) {
  if (password.length === 0) return { label: '', score: 0 }
  let score = 0
  if (password.length >= 8)    score++
  if (/[A-Z]/.test(password))  score++
  if (/[0-9]/.test(password))  score++
  if (/[^A-Za-z0-9]/.test(password)) score++  // special characters

  const labels = ['', 'Weak', 'Fair', 'Good', 'Strong']
  const colors = ['', 'red', 'orange', 'goldenrod', 'green']
  return { label: labels[score], color: colors[score], score }
}

// In the component:
const strength = getPasswordStrength(form.password)

// In the JSX:
<div style={{ color: strength.color, fontWeight: 600 }}>
  {strength.label && `Strength: ${strength.label}`}
</div>
Tip
For forms with complex validation, consider a library like React Hook Form or Zod + React Hook Form. They handle touched state, validation timing, and error display automatically, reducing the boilerplate you write by hand.
Warning
Client-side validation is for UX only — it can be bypassed. Always validate on the server too. Never trust the client.