Step-by-step
-
1
Mark up the form with HTML5 constraints
Use native HTML5 attributes for basic validation before JavaScript runs.
required,type="email",minlength, andpatternprevent 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
Intercept submission with preventDefault
Attach a
submitlistener to the<form>element — not the button. Callevent.preventDefault()immediately to stop the browser's default page navigation. This gives you full control of what happens next.javascriptconst form = document.getElementById('register-form'); form.addEventListener('submit', async (event) => { event.preventDefault(); await handleSubmit(form); }); -
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.javascriptfunction 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
Show and clear per-field errors
Each input has a sibling
.field-errorspan. Write two helpers — one to set an error, one to clear it. Add anis-invalidclass to the input itself so you can style the red border via CSS.javascriptfunction 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
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 afinallyblock.javascriptasync 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
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
showFieldErrorfor 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
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
FormDatato a plain object and stringify it. Set theContent-Typeheader explicitly.javascriptconst 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.