Programming Beginner 9 min

How to Handle Form Submission and Validation in Vanilla JS

Most form tutorials stop at preventing the default submission and reading field values. Real forms are harder: you need to validate before sending, show per-field errors clearly, disable the button while waiting, and handle server-side validation errors when the API rejects the data.

This guide builds a complete form handler: HTML5 validation for quick wins, a JavaScript submit handler using FormData, fetch, per-field error display, and a full round-trip with server-side 422 validation errors mapped back to specific fields.

No framework, no library — just the platform.

Step-by-step

  1. 1

    Mark up the form with HTML5 constraints

    Use native HTML5 attributes for basic validation before JavaScript runs. required, type="email", minlength, and pattern prevent obviously invalid submissions and are accessible by default. Add a sibling element per field for custom error messages.

    html
    <form id="register-form" novalidate>
      <div class="field">
        <label for="name">Name</label>
        <input id="name" name="name" type="text" required minlength="2">
        <span class="field-error" aria-live="polite"></span>
      </div>
    
      <div class="field">
        <label for="email">Email</label>
        <input id="email" name="email" type="email" required>
        <span class="field-error" aria-live="polite"></span>
      </div>
    
      <button type="submit">Register</button>
    </form>
  2. 2

    Intercept submission with preventDefault

    Attach a submit listener to the <form> element — not the button. Call event.preventDefault() immediately to stop the browser's default page navigation. This gives you full control of what happens next.

    javascript
    const form = document.getElementById('register-form');
    
    form.addEventListener('submit', async (event) => {
      event.preventDefault();
      await handleSubmit(form);
    });
  3. 3

    Validate fields before sending

    Use the Constraint Validation API (input.validity) to check each field. Write a small helper that reads the validation state and returns a human-readable message. Clear previous errors first, then re-validate.

    javascript
    function getValidationMessage(input) {
      if (input.validity.valueMissing) return 'This field is required.';
      if (input.validity.typeMismatch) return `Please enter a valid ${input.type}.`;
      if (input.validity.tooShort) return `Minimum ${input.minLength} characters required.`;
      if (input.validity.patternMismatch) return input.title || 'Invalid format.';
      return '';
    }
    
    function validateForm(form) {
      let valid = true;
      form.querySelectorAll('input').forEach((input) => {
        const msg = getValidationMessage(input);
        showFieldError(input, msg);
        if (msg) valid = false;
      });
      return valid;
    }
  4. 4

    Show and clear per-field errors

    Each input has a sibling .field-error span. Write two helpers — one to set an error, one to clear it. Add an is-invalid class to the input itself so you can style the red border via CSS.

    javascript
    function showFieldError(input, message) {
      const errorEl = input.closest('.field')?.querySelector('.field-error');
      if (!errorEl) return;
    
      if (message) {
        errorEl.textContent = message;
        input.classList.add('is-invalid');
        input.setAttribute('aria-invalid', 'true');
      } else {
        errorEl.textContent = '';
        input.classList.remove('is-invalid');
        input.removeAttribute('aria-invalid');
      }
    }
    
    // Clear errors when the user starts fixing them
    form.querySelectorAll('input').forEach((input) => {
      input.addEventListener('input', () => showFieldError(input, ''));
    });
  5. 5

    Collect data and disable the submit button

    new FormData(form) reads every named <input>, <select>, and <textarea> automatically. Disable the submit button before the request to prevent double-submission, and re-enable it in a finally block.

    javascript
    async function handleSubmit(form) {
      if (!validateForm(form)) return;
    
      const btn = form.querySelector('[type="submit"]');
      btn.disabled = true;
      btn.textContent = 'Sending…';
    
      const formData = new FormData(form);
    
      try {
        const response = await fetch('/api/register', {
          method: 'POST',
          body: formData, // No Content-Type header: browser sets multipart boundary
        });
    
        if (response.status === 422) {
          const { errors } = await response.json();
          applyServerErrors(form, errors);
          return;
        }
    
        if (!response.ok) throw new Error(`Server error: ${response.status}`);
    
        form.reset();
        showSuccess('Registration complete!');
      } catch (err) {
        showError(err.message);
      } finally {
        btn.disabled = false;
        btn.textContent = 'Register';
      }
    }
  6. 6

    Map 422 validation errors back to fields

    Server responses for validation errors (HTTP 422) typically return a JSON object mapping field names to error arrays. Iterate the errors object and call showFieldError for each field. This loops the feedback cycle cleanly without a page reload.

    javascript
    // Server returns: { errors: { email: ['Already taken.'], name: ['Too short.'] } }
    function applyServerErrors(form, errors) {
      Object.entries(errors).forEach(([fieldName, messages]) => {
        const input = form.querySelector(`[name="${fieldName}"]`);
        if (input) {
          showFieldError(input, messages[0]); // Show first error per field
        }
      });
    }
  7. 7

    Send JSON instead of FormData when needed

    If your API expects a JSON body (e.g., a REST API that doesn't accept multipart), convert FormData to a plain object and stringify it. Set the Content-Type header explicitly.

    javascript
    const payload = Object.fromEntries(new FormData(form));
    
    const response = await fetch('/api/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });

Tips & gotchas

  • Add <code>novalidate</code> to the form element to suppress native browser error bubbles and control the UX fully in JavaScript.
  • Always re-enable the submit button in <code>finally</code>, not in the success branch — errors would leave it permanently disabled otherwise.
  • Announce validation errors to screen readers with <code>aria-live="polite"</code> on error elements, and <code>aria-invalid="true"</code> on the failing input.
  • For checkboxes and radio buttons, <code>FormData</code> only includes checked values. If an unchecked box needs to send <code>false</code>, convert to a plain object and handle it explicitly.
  • Never trust client-side validation alone — always validate on the server. Client-side validation is UX, not security.

Wrapping up

A robust form handler is not much code — roughly 60 lines covering validation, submission, error display, and the server round-trip. The pattern here is reusable: swap in any endpoint, any field set. Master it once and you won't need a form library for most projects.

#JavaScript #Forms #Validation
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.