Form Validation Best Practices: Building User-Friendly Input Experience

Master HTML5 validation, JavaScript validation, form libraries and accessible validation techniques

Form Validation Best Practices: Building User-Friendly Input Experience

Form validation is a critical part of user interaction. This article explores various techniques and best practices for frontend form validation.

Validation Strategies

Validation Timing

Validation Timing Options:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   onBlur (on field exit)                            │
│   └── Validate when user leaves field, balanced    │
│                                                     │
│   onChange (on input)                               │
│   └── Real-time feedback, but may be too frequent  │
│                                                     │
│   onSubmit (on form submit)                         │
│   └── Unified validation, but delayed feedback     │
│                                                     │
│   Hybrid Strategy                                   │
│   └── First onBlur, then onChange                  │
│                                                     │
└─────────────────────────────────────────────────────┘
StrategyProsCons
onBlurBalanced experienceSlight delay
onChangeInstant feedbackCan be annoying
onSubmitUnified handlingDelayed feedback

HTML5 Native Validation

Validation Attributes

<!-- Required field -->
<input type="text" name="username" required />

<!-- Length limits -->
<input type="text" minlength="3" maxlength="20" />

<!-- Number range -->
<input type="number" min="0" max="100" step="1" />

<!-- Pattern matching -->
<input
  type="text"
  pattern="[A-Za-z0-9]+"
  title="Only letters and numbers allowed"
/>

<!-- Email validation -->
<input type="email" required />

<!-- URL validation -->
<input type="url" placeholder="https://example.com" />

<!-- Phone number -->
<input type="tel" pattern="[0-9]{11}" />

Custom Validation Messages

<form id="myForm">
  <input
    type="email"
    id="email"
    required
    oninvalid="this.setCustomValidity('Please enter a valid email address')"
    oninput="this.setCustomValidity('')"
  />
  <button type="submit">Submit</button>
</form>
// JavaScript custom validation
const emailInput = document.getElementById('email') as HTMLInputElement;

emailInput.addEventListener('invalid', (e) => {
  const input = e.target as HTMLInputElement;

  if (input.validity.valueMissing) {
    input.setCustomValidity('Email address is required');
  } else if (input.validity.typeMismatch) {
    input.setCustomValidity('Please enter a valid email format');
  }
});

emailInput.addEventListener('input', (e) => {
  (e.target as HTMLInputElement).setCustomValidity('');
});

CSS Validation States

/* Valid state */
input:valid {
  border-color: #10b981;
}

/* Invalid state */
input:invalid {
  border-color: #ef4444;
}

/* Invalid after user interaction */
input:user-invalid {
  border-color: #ef4444;
  background-color: #fef2f2;
}

/* Required fields */
input:required {
  border-left: 3px solid #3b82f6;
}

/* Optional fields */
input:optional {
  border-left: 3px solid #9ca3af;
}

/* In/out of range */
input:in-range {
  border-color: #10b981;
}

input:out-of-range {
  border-color: #ef4444;
}

JavaScript Validation

Validation Functions

// Validation rule type
type ValidationRule = {
  validate: (value: string) => boolean;
  message: string;
};

// Common validation rules
const validators = {
  required: (message = 'This field is required'): ValidationRule => ({
    validate: (value) => value.trim().length > 0,
    message,
  }),

  email: (message = 'Please enter a valid email address'): ValidationRule => ({
    validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
    message,
  }),

  minLength: (min: number, message?: string): ValidationRule => ({
    validate: (value) => value.length >= min,
    message: message || `At least ${min} characters required`,
  }),

  maxLength: (max: number, message?: string): ValidationRule => ({
    validate: (value) => value.length <= max,
    message: message || `Maximum ${max} characters allowed`,
  }),

  pattern: (regex: RegExp, message: string): ValidationRule => ({
    validate: (value) => regex.test(value),
    message,
  }),

  match: (fieldName: string, getValue: () => string, message?: string): ValidationRule => ({
    validate: (value) => value === getValue(),
    message: message || `Must match ${fieldName}`,
  }),
};

// Validate field
function validateField(value: string, rules: ValidationRule[]): string[] {
  const errors: string[] = [];

  for (const rule of rules) {
    if (!rule.validate(value)) {
      errors.push(rule.message);
    }
  }

  return errors;
}

// Usage
const emailErrors = validateField(email, [
  validators.required(),
  validators.email(),
]);

Form Validator Class

type FieldConfig = {
  rules: ValidationRule[];
  element: HTMLInputElement;
};

type FormErrors = Record<string, string[]>;

class FormValidator {
  private fields: Map<string, FieldConfig> = new Map();
  private errors: FormErrors = {};

  addField(name: string, element: HTMLInputElement, rules: ValidationRule[]) {
    this.fields.set(name, { element, rules });

    // Bind events
    element.addEventListener('blur', () => this.validateField(name));
    element.addEventListener('input', () => {
      if (this.errors[name]?.length) {
        this.validateField(name);
      }
    });
  }

  validateField(name: string): boolean {
    const field = this.fields.get(name);
    if (!field) return true;

    const { element, rules } = field;
    const errors = validateField(element.value, rules);

    this.errors[name] = errors;
    this.updateFieldUI(name, errors);

    return errors.length === 0;
  }

  validateAll(): boolean {
    let isValid = true;

    this.fields.forEach((_, name) => {
      if (!this.validateField(name)) {
        isValid = false;
      }
    });

    return isValid;
  }

  private updateFieldUI(name: string, errors: string[]) {
    const field = this.fields.get(name);
    if (!field) return;

    const { element } = field;
    const errorContainer = document.getElementById(`${name}-error`);

    if (errors.length > 0) {
      element.classList.add('is-invalid');
      element.classList.remove('is-valid');
      if (errorContainer) {
        errorContainer.textContent = errors[0];
        errorContainer.style.display = 'block';
      }
    } else {
      element.classList.remove('is-invalid');
      element.classList.add('is-valid');
      if (errorContainer) {
        errorContainer.style.display = 'none';
      }
    }
  }

  getErrors(): FormErrors {
    return { ...this.errors };
  }
}

React Form Validation

React Hook Form

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

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

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isSubmitting },
  } = useForm<FormData>();

  const password = watch('password');

  const onSubmit = async (data: FormData) => {
    await registerUser(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Please enter a valid email address',
            },
          })}
          aria-invalid={errors.email ? 'true' : 'false'}
        />
        {errors.email && (
          <span role="alert">{errors.email.message}</span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters',
            },
            pattern: {
              value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
              message: 'Password must contain uppercase, lowercase and number',
            },
          })}
        />
        {errors.password && (
          <span role="alert">{errors.password.message}</span>
        )}
      </div>

      <div>
        <label htmlFor="confirmPassword">Confirm Password</label>
        <input
          id="confirmPassword"
          type="password"
          {...register('confirmPassword', {
            required: 'Please confirm password',
            validate: (value) =>
              value === password || 'Passwords do not match',
          })}
        />
        {errors.confirmPassword && (
          <span role="alert">{errors.confirmPassword.message}</span>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Register'}
      </button>
    </form>
  );
}

Zod Schema Validation

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// Define Schema
const registrationSchema = z
  .object({
    email: z
      .string()
      .min(1, 'Email is required')
      .email('Please enter a valid email address'),
    password: z
      .string()
      .min(8, 'Password must be at least 8 characters')
      .regex(
        /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
        'Password must contain uppercase, lowercase and number'
      ),
    confirmPassword: z.string().min(1, 'Please confirm password'),
    age: z
      .number()
      .min(18, 'Must be at least 18 years old')
      .max(120, 'Invalid age'),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

type RegistrationData = z.infer<typeof registrationSchema>;

function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<RegistrationData>({
    resolver: zodResolver(registrationSchema),
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
    </form>
  );
}

Accessible Validation

ARIA Attributes

<div class="form-group">
  <label for="email" id="email-label">
    Email Address
    <span aria-hidden="true">*</span>
  </label>

  <input
    type="email"
    id="email"
    name="email"
    required
    aria-required="true"
    aria-labelledby="email-label"
    aria-describedby="email-hint email-error"
    aria-invalid="false"
  />

  <span id="email-hint" class="hint">
    We won't share your email
  </span>

  <span id="email-error" class="error" role="alert" aria-live="polite">
    <!-- Dynamic error message -->
  </span>
</div>

Screen Reader Friendly

interface FormFieldProps {
  id: string;
  label: string;
  error?: string;
  hint?: string;
  required?: boolean;
  children: React.ReactNode;
}

function FormField({
  id,
  label,
  error,
  hint,
  required,
  children,
}: FormFieldProps) {
  const hintId = `${id}-hint`;
  const errorId = `${id}-error`;
  const describedBy = [hint && hintId, error && errorId]
    .filter(Boolean)
    .join(' ');

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>

      {React.cloneElement(children as React.ReactElement, {
        id,
        'aria-required': required,
        'aria-invalid': !!error,
        'aria-describedby': describedBy || undefined,
      })}

      {hint && (
        <span id={hintId} className="hint">
          {hint}
        </span>
      )}

      {error && (
        <span id={errorId} className="error" role="alert" aria-live="polite">
          {error}
        </span>
      )}
    </div>
  );
}

// Usage
<FormField
  id="email"
  label="Email"
  required
  error={errors.email?.message}
  hint="We won't share your email"
>
  <input type="email" {...register('email')} />
</FormField>

Best Practices Summary

Form Validation Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   User Experience                                   │
│   ├── Instant but non-intrusive feedback           │
│   ├── Clear error messages                         │
│   ├── Show valid state confirmation                │
│   └── Preserve user input                          │
│                                                     │
│   Technical Implementation                          │
│   ├── Client + server dual validation              │
│   ├── Use validation libraries                     │
│   ├── Define reusable validation rules             │
│   └── Handle async validation                      │
│                                                     │
│   Accessibility                                     │
│   ├── Use correct ARIA attributes                  │
│   ├── Associate error messages with fields         │
│   ├── Support keyboard navigation                  │
│   └── Screen reader friendly                       │
│                                                     │
└─────────────────────────────────────────────────────┘
ScenarioRecommendation
Simple formsHTML5 native validation
Complex formsReact Hook Form + Zod
Real-time validationdebounce + async validation
Cross-field validationSchema-level validation

Good form validation is the guarantee of successful user submission.