JavaScript 11 views

Custom Form Validation with Feedback

Create a reusable form validation system that provides user feedback for valid and invalid inputs.

By TWC Team • Feb 28, 2026

Code

/**
 * Custom Form Validation with Feedback
 * Reusable validator that applies HTML5 constraint feedback + custom rules before submit.
 * Usage: attachFormValidator(document.querySelector('#myForm'), { onSubmit: async (data) => {...} });
 */
(() => {
  'use strict';

  // Function to validate form inputs (extends the provided baseline with better feedback + edge cases)
  const validateForm = (form) => {
    if (!(form instanceof HTMLFormElement)) throw new TypeError('validateForm(form) expects an HTMLFormElement');

    let isValid = true;
    const inputs = form.querySelectorAll('input, textarea, select');

    inputs.forEach((input) => {
      // Reset previous custom error; required for accurate re-validation
      input.setCustomValidity('');

      // Skip disabled, non-submittable, or no-name controls
      if (input.disabled || !input.name) return;

      const value = (input.value ?? '').trim();

      // Check for empty fields
      if (input.required && !value) {
        input.setCustomValidity('This field cannot be empty');
        isValid = false;
      }

      // Custom validation for email (only if user typed something)
      if (input.type === 'email' && value) {
        const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailPattern.test(value)) {
          input.setCustomValidity('Please enter a valid email address');
          isValid = false;
        }
      }

      // Ensure built-in constraints (minlength, pattern, etc.) are surfaced
      if (!input.checkValidity()) isValid = false;
    });

    // Show a single, native validation bubble on the first invalid control for best UX
    if (!isValid) form.reportValidity();

    return isValid;
  };

  const serializeForm = (form) => {
    const data = new FormData(form);
    // Functional reduction into a plain object; supports multi-value fields (e.g., checkboxes)
    return [...data.entries()].reduce((acc, [key, val]) => {
      const value = typeof val === 'string' ? val.trim() : val;
      acc[key] = key in acc ? [].concat(acc[key], value) : value;
      return acc;
    }, {});
  };

  const attachFormValidator = (form, { onSubmit } = {}) => {
    if (!(form instanceof HTMLFormElement)) throw new TypeError('attachFormValidator expects an HTMLFormElement');

    // Input-level live feedback: validates on blur and clears error on input
    const onBlur = (event) => {
      const control = event.target;
      if (!(control instanceof HTMLElement) || !form.contains(control)) return;
      // Validate whole form to keep cross-field rules extensible and consistent
      try { validateForm(form); } catch { /* no-op: avoid breaking UX */ }
    };

    const onInput = (event) => {
      const control = event.target;
      if (!(control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement || control instanceof HTMLSelectElement)) return;
      // Clearing custom validity allows native messages to update as user types
      control.setCustomValidity('');
    };

    // Event listener for form submission (supports async submit handlers safely)
    const onSubmitHandler = async (event) => {
      try {
        if (!validateForm(form)) {
          event.preventDefault();
          return;
        }

        if (typeof onSubmit === 'function') {
          event.preventDefault(); // allow custom async submission
          const payload = serializeForm(form);
          await onSubmit(payload, form);
        }
      } catch (error) {
        event.preventDefault();
        console.error('Form submission failed:', error);
        alert('Something went wrong. Please try again.');
      }
    };

    form.addEventListener('submit', onSubmitHandler);
    form.addEventListener('blur', onBlur, true);  // capture: handle children reliably
    form.addEventListener('input', onInput);

    // Return cleanup to avoid leaks in SPA-like environments
    return () => {
      form.removeEventListener('submit', onSubmitHandler);
      form.removeEventListener('blur', onBlur, true);
      form.removeEventListener('input', onInput);
    };
  };

  // Example usage:
  // const cleanup = attachFormValidator(document.querySelector('#myForm'), {
  //   onSubmit: async (data) => fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
  // });
  window.attachFormValidator = attachFormValidator; // expose a single, intentional API surface
})();
Back to Snippets