JavaScript — Form Handling
Forms are the primary mechanism for collecting user input on the web. From login screens and sign-up flows to complex multi-step wizards and file uploads, forms drive the interactive web. JavaScript gives us the tools to read form data, validate it in real time, submit it asynchronously, and provide rich feedback.
HTML provides built-in validation through attributes like required, minlength, pattern, and type="email". This is known as constraint validation, and it works without any JavaScript. However, HTML-only validation has limitations: you cannot customize error messages fully, show errors in real time as the user types, or prevent form submission for complex cross-field rules.
The best approach is progressive enhancement: start with semantic HTML and built-in validation, then layer JavaScript on top to enhance the experience with real-time feedback, custom validation logic, async submission, and polished UI states. This way the form works even if JavaScript fails to load.
| 1 | <!-- Progressive enhancement: HTML validation works first --> |
| 2 | <form id="signup" action="/api/signup" method="POST" novalidate> |
| 3 | <label for="email">Email</label> |
| 4 | <input |
| 5 | type="email" |
| 6 | id="email" |
| 7 | name="email" |
| 8 | required |
| 9 | minlength="5" |
| 10 | autocomplete="email" |
| 11 | /> |
| 12 | |
| 13 | <label for="password">Password</label> |
| 14 | <input |
| 15 | type="password" |
| 16 | id="password" |
| 17 | name="password" |
| 18 | required |
| 19 | minlength="8" |
| 20 | autocomplete="new-password" |
| 21 | /> |
| 22 | |
| 23 | <button type="submit">Sign Up</button> |
| 24 | </form> |
| 25 | |
| 26 | <!-- JS layers on: real-time validation, async submit, feedback --> |
The DOM provides several APIs for accessing forms and their controls. The legacy document.forms collection gives you all forms on the page, keyed by index or by the name attribute. Each form exposes an elements collection containing all its form controls.
| 1 | // Access forms by index or name |
| 2 | const form1 = document.forms[0]; |
| 3 | const loginForm = document.forms.login; // <form name="login"> |
| 4 | |
| 5 | // Access form elements |
| 6 | const username = loginForm.elements.username; |
| 7 | const email = loginForm.elements.email; |
| 8 | const submit = loginForm.elements.submit; |
| 9 | |
| 10 | // Query selectors also work |
| 11 | const form = document.querySelector('#signup-form'); |
| 12 | const inputs = form.querySelectorAll('input, select, textarea'); |
| 13 | |
| 14 | // Named access — input name becomes property |
| 15 | const form = document.forms.checkout; |
| 16 | console.log(form.email.value); // direct property access |
| 17 | console.log(form['shipping-address'].value); // bracket for hyphens |
| 18 | |
| 19 | // The FormData API — modern structured access |
| 20 | const data = new FormData(form); |
| 21 | console.log(data.get('email')); // "user@example.com" |
| 22 | console.log(data.getAll('hobbies')); // ["reading", "coding"] |
info
Different input types expose their values through different properties. Text inputs, selects, and textareas use .value. Checkboxes use .checked for state and .value for the submitted data. Radio buttons are grouped by name — you iterate the group to find the checked one. File inputs expose a FileList via .files.
| 1 | // Text / email / number / password |
| 2 | const textValue = input.value; |
| 3 | |
| 4 | // Checkbox — checked state, not value |
| 5 | const isChecked = checkbox.checked; |
| 6 | const checkboxValue = checkbox.value; // submitted value |
| 7 | |
| 8 | // Radio buttons — find checked in group |
| 9 | const radios = form.elements.gender; // RadioNodeList |
| 10 | let selectedGender; |
| 11 | for (const radio of radios) { |
| 12 | if (radio.checked) { |
| 13 | selectedGender = radio.value; |
| 14 | break; |
| 15 | } |
| 16 | } |
| 17 | // Or spread + find |
| 18 | const gender = [...form.elements.gender] |
| 19 | .find(r => r.checked)?.value; |
| 20 | |
| 21 | // Select (single) |
| 22 | const selectedValue = select.value; |
| 23 | const selectedOption = select.options[select.selectedIndex]; |
| 24 | const optionText = selectedOption.textContent; |
| 25 | |
| 26 | // Select (multiple) |
| 27 | const selectedValues = [...select.selectedOptions] |
| 28 | .map(opt => opt.value); |
| 29 | |
| 30 | // Textarea — same as text input |
| 31 | const bio = textarea.value; |
| 32 | |
| 33 | // File input |
| 34 | const files = fileInput.files; // FileList |
| 35 | if (files.length > 0) { |
| 36 | const file = files[0]; |
| 37 | console.log(file.name, file.size, file.type); |
| 38 | } |
You can programmatically set form values for any control. After setting values, dispatch input and change events so that any attached listeners react accordingly. The form.reset() method restores all controls to their default values (as specified in the HTML).
| 1 | // Setting values by control |
| 2 | input.value = 'hello@example.com'; |
| 3 | checkbox.checked = true; |
| 4 | radio.checked = true; |
| 5 | select.value = 'uk'; |
| 6 | textarea.value = 'Some pre-filled text'; |
| 7 | |
| 8 | // Trigger events after setting (so listeners fire) |
| 9 | input.dispatchEvent(new Event('input', { bubbles: true })); |
| 10 | input.dispatchEvent(new Event('change', { bubbles: true })); |
| 11 | |
| 12 | // Select option by index |
| 13 | select.selectedIndex = 2; |
| 14 | |
| 15 | // Check a specific radio in a group |
| 16 | form.elements.gender.value = 'female'; // sets checked state |
| 17 | |
| 18 | // Setting multiple values at once |
| 19 | function setFormValues(form, values) { |
| 20 | for (const [name, value] of Object.entries(values)) { |
| 21 | const control = form.elements[name]; |
| 22 | if (!control) continue; |
| 23 | if (control.type === 'checkbox') { |
| 24 | control.checked = Boolean(value); |
| 25 | } else if (control.type === 'radio') { |
| 26 | control.value = value; // selects matching radio |
| 27 | } else { |
| 28 | control.value = value; |
| 29 | } |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | setFormValues(form, { |
| 34 | email: 'user@example.com', |
| 35 | subscribe: true, |
| 36 | plan: 'premium' |
| 37 | }); |
| 38 | |
| 39 | // Reset form to defaults |
| 40 | form.reset(); // restores HTML defaults |
Forms emit several events throughout their lifecycle. The submit event fires when the user clicks a submit button or presses Enter in a text field — you must call preventDefault() to handle submission with JavaScript. The input event fires immediately on every keystroke or value change, making it ideal for real-time validation. The change event fires after the user commits a change (on blur for text inputs, immediately for checkboxes and radios). Focus events (focus, blur) track which field the user is interacting with, and the invalid event fires on native validation failure.
| 1 | form.addEventListener('submit', (e) => { |
| 2 | e.preventDefault(); // stop browser submission |
| 3 | console.log('Form submitted'); |
| 4 | // handle with fetch / XHR |
| 5 | }); |
| 6 | |
| 7 | input.addEventListener('input', (e) => { |
| 8 | console.log('Value changed:', e.target.value); |
| 9 | // real-time validation here |
| 10 | }); |
| 11 | |
| 12 | input.addEventListener('change', (e) => { |
| 13 | console.log('User committed change:', e.target.value); |
| 14 | // fires on blur for text, immediately for checkbox/radio |
| 15 | }); |
| 16 | |
| 17 | input.addEventListener('focus', (e) => { |
| 18 | console.log('Focused on:', e.target.name); |
| 19 | // show hint, highlight field |
| 20 | }); |
| 21 | |
| 22 | input.addEventListener('blur', (e) => { |
| 23 | console.log('Left field:', e.target.name); |
| 24 | // validate on field exit |
| 25 | }); |
| 26 | |
| 27 | form.addEventListener('reset', (e) => { |
| 28 | console.log('Form was reset'); |
| 29 | // clear validation state |
| 30 | }); |
| 31 | |
| 32 | input.addEventListener('invalid', (e) => { |
| 33 | console.log('Native validation failed:', e.target.validationMessage); |
| 34 | // custom error display (but preventDefault to suppress native bubble) |
| 35 | e.preventDefault(); |
| 36 | }); |
Real-time validation listens to the input event and provides feedback as the user types. The key challenge is avoiding excessive validation on every keystroke — debouncing delays execution until the user pauses typing. Combine this with the Constraint Validation API for native validation checks, and add visual cues like border colors and error messages that appear and disappear smoothly.
| 1 | // Debounce helper — validates after user stops typing |
| 2 | function debounce(fn, delay = 300) { |
| 3 | let timer; |
| 4 | return function (...args) { |
| 5 | clearTimeout(timer); |
| 6 | timer = setTimeout(() => fn.apply(this, args), delay); |
| 7 | }; |
| 8 | } |
| 9 | |
| 10 | // Real-time validation with debouncing |
| 11 | const emailInput = document.getElementById('email'); |
| 12 | const emailError = document.getElementById('email-error'); |
| 13 | |
| 14 | const validateEmail = debounce(() => { |
| 15 | const value = emailInput.value.trim(); |
| 16 | |
| 17 | if (value.length === 0) { |
| 18 | showError('Email is required'); |
| 19 | } else if (!emailInput.validity.valid) { |
| 20 | showError(emailInput.validationMessage); |
| 21 | } else if (!value.includes('@')) { |
| 22 | showError('Email must contain @'); |
| 23 | } else { |
| 24 | clearError(); |
| 25 | } |
| 26 | }, 250); |
| 27 | |
| 28 | function showError(message) { |
| 29 | emailError.textContent = message; |
| 30 | emailError.style.display = 'block'; |
| 31 | emailInput.style.borderColor = '#FF4444'; |
| 32 | } |
| 33 | |
| 34 | function clearError() { |
| 35 | emailError.textContent = ''; |
| 36 | emailError.style.display = 'none'; |
| 37 | emailInput.style.borderColor = '#2A2A3E'; |
| 38 | } |
| 39 | |
| 40 | emailInput.addEventListener('input', validateEmail); |
| 41 | emailInput.addEventListener('blur', () => { |
| 42 | // Validate immediately on blur (no debounce) |
| 43 | validateEmail.flush?.(); |
| 44 | }); |
| 45 | |
| 46 | // Validate on form submit too |
| 47 | form.addEventListener('submit', (e) => { |
| 48 | if (!emailInput.validity.valid) { |
| 49 | e.preventDefault(); |
| 50 | showError(emailInput.validationMessage); |
| 51 | emailInput.focus(); |
| 52 | } |
| 53 | }); |
best practice
The Constraint Validation API is the browser's built-in validation engine. Every form control exposes a validity object with boolean properties for different validation states: valueMissing (required but empty), typeMismatch (email/url pattern), patternMismatch (regex mismatch), and more. Call checkValidity() on a control or the whole form to trigger validation, and reportValidity() to show native validation bubbles.
| 1 | // Check individual field validity |
| 2 | const input = document.getElementById('email'); |
| 3 | if (input.checkValidity()) { |
| 4 | console.log('Field is valid'); |
| 5 | } else { |
| 6 | console.log(input.validationMessage); // browser message |
| 7 | console.log(input.validity); |
| 8 | // { |
| 9 | // valid: false, |
| 10 | // valueMissing: false, |
| 11 | // typeMismatch: true, |
| 12 | // patternMismatch: false, |
| 13 | // tooShort: false, |
| 14 | // tooLong: false, |
| 15 | // rangeUnderflow: false, |
| 16 | // rangeOverflow: false, |
| 17 | // stepMismatch: false, |
| 18 | // badInput: false, |
| 19 | // customError: false |
| 20 | // } |
| 21 | } |
| 22 | |
| 23 | // Check entire form |
| 24 | if (form.checkValidity()) { |
| 25 | // all fields pass |
| 26 | } else { |
| 27 | // at least one field is invalid |
| 28 | } |
| 29 | |
| 30 | // Show native validation bubbles |
| 31 | form.reportValidity(); |
| 32 | |
| 33 | // Custom validation message |
| 34 | const password = document.getElementById('password'); |
| 35 | if (password.value.length < 8) { |
| 36 | password.setCustomValidity( |
| 37 | 'Password must be at least 8 characters' |
| 38 | ); |
| 39 | } else { |
| 40 | password.setCustomValidity(''); // clear |
| 41 | } |
| 42 | |
| 43 | // The validity.customError flag is true when setCustomValidity |
| 44 | // has a non-empty message |
| 45 | console.log(password.validity.customError); |
note
A reusable FormValidator class encapsulates validation rules, error display, and visual state management. Define rules declaratively — required fields, email format, min/max length, custom regex patterns, numeric ranges, and custom functions. The validator walks through all rules, collects errors, applies CSS classes for valid/invalid states, and returns a summary of the validation result.
| 1 | class FormValidator { |
| 2 | constructor(form, options = {}) { |
| 3 | this.form = form; |
| 4 | this.rules = []; |
| 5 | this.showErrors = options.showErrors ?? true; |
| 6 | this.errorClass = options.errorClass || 'field-error'; |
| 7 | this.validClass = options.validClass || 'field-valid'; |
| 8 | } |
| 9 | |
| 10 | addRule(input, validations) { |
| 11 | this.rules.push({ input, validations }); |
| 12 | } |
| 13 | |
| 14 | validateField(input) { |
| 15 | const rule = this.rules.find(r => r.input === input); |
| 16 | if (!rule) return []; |
| 17 | |
| 18 | const errors = []; |
| 19 | for (const v of rule.validations) { |
| 20 | const value = input.value.trim(); |
| 21 | if (v.required && value.length === 0) { |
| 22 | errors.push(v.message || 'This field is required'); |
| 23 | } else if (v.minLength && value.length < v.minLength) { |
| 24 | errors.push(v.message || `Minimum ${v.minLength} characters`); |
| 25 | } else if (v.maxLength && value.length > v.maxLength) { |
| 26 | errors.push(v.message || `Maximum ${v.maxLength} characters`); |
| 27 | } else if (v.pattern && !v.pattern.test(value)) { |
| 28 | errors.push(v.message || 'Invalid format'); |
| 29 | } else if (v.min !== undefined && Number(value) < v.min) { |
| 30 | errors.push(v.message || `Minimum value is ${v.min}`); |
| 31 | } else if (v.max !== undefined && Number(value) > v.max) { |
| 32 | errors.push(v.message || `Maximum value is ${v.max}`); |
| 33 | } else if (v.custom && !v.custom(value, input)) { |
| 34 | errors.push(v.message || 'Invalid value'); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | this.updateFieldState(input, errors); |
| 39 | return errors; |
| 40 | } |
| 41 | |
| 42 | updateFieldState(input, errors) { |
| 43 | const errorEl = input.nextElementSibling |
| 44 | ?.matches('.error-message') |
| 45 | ? input.nextElementSibling |
| 46 | : this.findOrCreateErrorEl(input); |
| 47 | |
| 48 | if (errors.length > 0) { |
| 49 | input.classList.add(this.errorClass); |
| 50 | input.classList.remove(this.validClass); |
| 51 | errorEl.textContent = errors[0]; |
| 52 | errorEl.style.display = 'block'; |
| 53 | } else if (input.value.trim().length > 0) { |
| 54 | input.classList.remove(this.errorClass); |
| 55 | input.classList.add(this.validClass); |
| 56 | errorEl.textContent = ''; |
| 57 | errorEl.style.display = 'none'; |
| 58 | } else { |
| 59 | input.classList.remove(this.errorClass, this.validClass); |
| 60 | errorEl.textContent = ''; |
| 61 | errorEl.style.display = 'none'; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | findOrCreateErrorEl(input) { |
| 66 | let el = input.parentElement.querySelector('.error-message'); |
| 67 | if (!el) { |
| 68 | el = document.createElement('div'); |
| 69 | el.className = 'error-message'; |
| 70 | el.style.cssText = 'font-size:10px;color:#FF4444;' + |
| 71 | 'font-family:monospace;margin-top:2px;display:none'; |
| 72 | input.parentElement.appendChild(el); |
| 73 | } |
| 74 | return el; |
| 75 | } |
| 76 | |
| 77 | validate() { |
| 78 | let allErrors = []; |
| 79 | for (const rule of this.rules) { |
| 80 | const errors = this.validateField(rule.input); |
| 81 | if (errors.length > 0) { |
| 82 | allErrors.push({ |
| 83 | input: rule.input, |
| 84 | errors |
| 85 | }); |
| 86 | } |
| 87 | } |
| 88 | return { |
| 89 | valid: allErrors.length === 0, |
| 90 | errors: allErrors |
| 91 | }; |
| 92 | } |
| 93 | |
| 94 | reset() { |
| 95 | for (const rule of this.rules) { |
| 96 | const el = rule.input; |
| 97 | el.classList.remove(this.errorClass, this.validClass); |
| 98 | const errorEl = el.parentElement |
| 99 | .querySelector('.error-message'); |
| 100 | if (errorEl) { |
| 101 | errorEl.textContent = ''; |
| 102 | errorEl.style.display = 'none'; |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // Usage |
| 109 | const validator = new FormValidator(form, { |
| 110 | errorClass: 'border-red', |
| 111 | validClass: 'border-green' |
| 112 | }); |
| 113 | |
| 114 | validator.addRule(form.email, [ |
| 115 | { required: true, message: 'Email is required' }, |
| 116 | { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, |
| 117 | message: 'Invalid email format' }, |
| 118 | { maxLength: 100, message: 'Email too long' } |
| 119 | ]); |
| 120 | |
| 121 | validator.addRule(form.age, [ |
| 122 | { required: true }, |
| 123 | { min: 18, message: 'Must be 18 or older' }, |
| 124 | { max: 120, message: 'Enter a valid age' } |
| 125 | ]); |
| 126 | |
| 127 | form.addEventListener('submit', (e) => { |
| 128 | e.preventDefault(); |
| 129 | const result = validator.validate(); |
| 130 | if (!result.valid) { |
| 131 | // Focus first error field |
| 132 | result.errors[0].input.focus(); |
| 133 | return; |
| 134 | } |
| 135 | // Submit form... |
| 136 | }); |
Modern forms submit data asynchronously using the Fetch API instead of traditional full-page POST. This requires preventing the default submission, collecting form data, sending it to the server, and handling the response. You need to manage three visual states: loading (disable button, show spinner), error (show message with retry), and success (show confirmation, optionally reset the form).
| 1 | form.addEventListener('submit', async (e) => { |
| 2 | e.preventDefault(); |
| 3 | |
| 4 | // Validate first |
| 5 | const result = validator.validate(); |
| 6 | if (!result.valid) return; |
| 7 | |
| 8 | // Loading state |
| 9 | const submitBtn = form.querySelector('button[type="submit"]'); |
| 10 | const originalText = submitBtn.textContent; |
| 11 | submitBtn.disabled = true; |
| 12 | submitBtn.innerHTML = 'Sending... <span class="spinner"></span>'; |
| 13 | |
| 14 | const errorEl = form.querySelector('.form-error'); |
| 15 | errorEl.style.display = 'none'; |
| 16 | |
| 17 | try { |
| 18 | // Option A: Send as JSON |
| 19 | const data = Object.fromEntries(new FormData(form)); |
| 20 | const response = await fetch('/api/submit', { |
| 21 | method: 'POST', |
| 22 | headers: { 'Content-Type': 'application/json' }, |
| 23 | body: JSON.stringify(data) |
| 24 | }); |
| 25 | |
| 26 | // Option B: Send as multipart/form-data (includes files) |
| 27 | const formData = new FormData(form); |
| 28 | const response2 = await fetch('/api/upload', { |
| 29 | method: 'POST', |
| 30 | body: formData // no Content-Type — browser sets with boundary |
| 31 | }); |
| 32 | |
| 33 | if (!response.ok) { |
| 34 | const err = await response.json().catch(() => ({})); |
| 35 | throw new Error(err.message || `HTTP ${response.status}`); |
| 36 | } |
| 37 | |
| 38 | const responseData = await response.json(); |
| 39 | |
| 40 | // Success state |
| 41 | submitBtn.innerHTML = '✓ Submitted'; |
| 42 | submitBtn.style.background = '#00FF41'; |
| 43 | |
| 44 | // Show success message |
| 45 | const successEl = form.querySelector('.form-success'); |
| 46 | successEl.textContent = responseData.message || 'Form submitted!'; |
| 47 | successEl.style.display = 'block'; |
| 48 | |
| 49 | // Optionally reset |
| 50 | form.reset(); |
| 51 | validator.reset(); |
| 52 | |
| 53 | } catch (error) { |
| 54 | // Error state |
| 55 | submitBtn.disabled = false; |
| 56 | submitBtn.innerHTML = originalText; |
| 57 | errorEl.textContent = error.message || 'Submission failed'; |
| 58 | errorEl.style.display = 'block'; |
| 59 | } |
| 60 | }); |
warning
Multi-step (wizard) forms break a long form into manageable sections. Each step is a logical grouping of fields. The user navigates forward and backward, with validation at each step before allowing progression. A progress indicator shows the current step and overall completion. Steps are typically implemented as hidden <fieldset> elements, toggled via CSS classes.
| 1 | class MultiStepForm { |
| 2 | constructor(form, options = {}) { |
| 3 | this.form = form; |
| 4 | this.steps = form.querySelectorAll('.form-step'); |
| 5 | this.currentStep = 0; |
| 6 | this.progressEl = options.progressEl; |
| 7 | this.validators = options.validators || []; |
| 8 | this.showStep(0); |
| 9 | this.bindNavigation(); |
| 10 | } |
| 11 | |
| 12 | showStep(index) { |
| 13 | this.steps.forEach((step, i) => { |
| 14 | step.style.display = i === index ? 'block' : 'none'; |
| 15 | step.classList.toggle('active', i === index); |
| 16 | }); |
| 17 | this.updateProgress(index); |
| 18 | this.updateButtons(index); |
| 19 | } |
| 20 | |
| 21 | updateProgress(index) { |
| 22 | if (!this.progressEl) return; |
| 23 | const total = this.steps.length; |
| 24 | const pct = ((index + 1) / total) * 100; |
| 25 | this.progressEl.style.width = `${pct}%`; |
| 26 | this.progressEl.textContent = `Step ${index + 1} of ${total}`; |
| 27 | } |
| 28 | |
| 29 | updateButtons(index) { |
| 30 | const prevBtn = this.form.querySelector('.btn-prev'); |
| 31 | const nextBtn = this.form.querySelector('.btn-next'); |
| 32 | const submitBtn = this.form.querySelector('.btn-submit'); |
| 33 | |
| 34 | if (prevBtn) { |
| 35 | prevBtn.style.display = index === 0 ? 'none' : 'inline-block'; |
| 36 | } |
| 37 | if (nextBtn) { |
| 38 | nextBtn.style.display = index === this.steps.length - 1 |
| 39 | ? 'none' : 'inline-block'; |
| 40 | } |
| 41 | if (submitBtn) { |
| 42 | submitBtn.style.display = index === this.steps.length - 1 |
| 43 | ? 'inline-block' : 'none'; |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | validateCurrentStep() { |
| 48 | const validator = this.validators[this.currentStep]; |
| 49 | if (!validator) return true; |
| 50 | return validator.validate().valid; |
| 51 | } |
| 52 | |
| 53 | next() { |
| 54 | if (!this.validateCurrentStep()) return; |
| 55 | if (this.currentStep < this.steps.length - 1) { |
| 56 | this.currentStep++; |
| 57 | this.showStep(this.currentStep); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | prev() { |
| 62 | if (this.currentStep > 0) { |
| 63 | this.currentStep--; |
| 64 | this.showStep(this.currentStep); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | bindNavigation() { |
| 69 | this.form.querySelector('.btn-next') |
| 70 | ?.addEventListener('click', () => this.next()); |
| 71 | this.form.querySelector('.btn-prev') |
| 72 | ?.addEventListener('click', () => this.prev()); |
| 73 | this.form.addEventListener('submit', (e) => { |
| 74 | if (this.currentStep !== this.steps.length - 1) { |
| 75 | e.preventDefault(); |
| 76 | return; |
| 77 | } |
| 78 | }); |
| 79 | } |
| 80 | } |
pro tip
Dynamic forms adapt to user input in real time. You can add or remove fields (e.g., multiple phone numbers), show or hide sections based on a selection (conditional fields), and chain dependent dropdowns where the options in one select depend on the value of another (e.g., country → state → city). These patterns keep forms concise and relevant to the user's choices.
| 1 | // Add / remove fields dynamically |
| 2 | const container = document.getElementById('phones-container'); |
| 3 | let phoneCount = 1; |
| 4 | |
| 5 | function addPhoneField() { |
| 6 | phoneCount++; |
| 7 | const div = document.createElement('div'); |
| 8 | div.className = 'phone-field'; |
| 9 | div.innerHTML = ` |
| 10 | <input type="tel" name="phone_${phoneCount}" |
| 11 | placeholder="Phone ${phoneCount}" /> |
| 12 | <button type="button" class="remove-phone" |
| 13 | onclick="this.parentElement.remove()">×</button> |
| 14 | `; |
| 15 | container.appendChild(div); |
| 16 | } |
| 17 | |
| 18 | // Conditional fields — show/hide based on selection |
| 19 | const hasCompany = document.getElementById('has_company'); |
| 20 | const companyFields = document.getElementById('company-fields'); |
| 21 | |
| 22 | hasCompany.addEventListener('change', () => { |
| 23 | companyFields.style.display = |
| 24 | hasCompany.checked ? 'block' : 'none'; |
| 25 | // Disable/reenable to prevent validation of hidden fields |
| 26 | const inputs = companyFields.querySelectorAll('input, select'); |
| 27 | inputs.forEach(inp => { |
| 28 | inp.disabled = !hasCompany.checked; |
| 29 | }); |
| 30 | }); |
| 31 | |
| 32 | // Dependent dropdowns — country → state → city |
| 33 | const countrySelect = document.getElementById('country'); |
| 34 | const stateSelect = document.getElementById('state'); |
| 35 | const citySelect = document.getElementById('city'); |
| 36 | |
| 37 | countrySelect.addEventListener('change', async () => { |
| 38 | const country = countrySelect.value; |
| 39 | stateSelect.innerHTML = '<option>Loading...</option>'; |
| 40 | citySelect.innerHTML = '<option value="">Select city</option>'; |
| 41 | |
| 42 | const states = await fetchStates(country); |
| 43 | stateSelect.innerHTML = states.map(s => |
| 44 | `<option value="${s.code}">${s.name}</option>` |
| 45 | ).join(''); |
| 46 | }); |
| 47 | |
| 48 | stateSelect.addEventListener('change', async () => { |
| 49 | const state = stateSelect.value; |
| 50 | citySelect.innerHTML = '<option>Loading...</option>'; |
| 51 | const cities = await fetchCities(state); |
| 52 | citySelect.innerHTML = cities.map(c => |
| 53 | `<option value="${c}">${c}</option>` |
| 54 | ).join(''); |
| 55 | }); |
File uploads require special handling. Validation covers file type (check file.type against allowed MIME types) and file size (check file.size in bytes). A drag-and-drop zone enhances UX by allowing users to drop files directly. Before uploading, generate previews for images using FileReader or URL.createObjectURL. For progress tracking during upload, use XMLHttpRequest with its upload.progress event — the Fetch API does not natively support upload progress.
| 1 | // File validation |
| 2 | const fileInput = document.getElementById('avatar'); |
| 3 | const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; |
| 4 | const maxSize = 5 * 1024 * 1024; // 5MB |
| 5 | |
| 6 | fileInput.addEventListener('change', () => { |
| 7 | const file = fileInput.files[0]; |
| 8 | if (!file) return; |
| 9 | |
| 10 | if (!allowedTypes.includes(file.type)) { |
| 11 | showError('Only JPG, PNG, and WebP allowed'); |
| 12 | fileInput.value = ''; |
| 13 | return; |
| 14 | } |
| 15 | |
| 16 | if (file.size > maxSize) { |
| 17 | showError('File must be under 5MB'); |
| 18 | fileInput.value = ''; |
| 19 | return; |
| 20 | } |
| 21 | |
| 22 | // Generate preview |
| 23 | const reader = new FileReader(); |
| 24 | reader.onload = (e) => { |
| 25 | previewImg.src = e.target.result; |
| 26 | previewImg.style.display = 'block'; |
| 27 | }; |
| 28 | reader.readAsDataURL(file); |
| 29 | // Alternative: URL.createObjectURL(file) |
| 30 | }); |
| 31 | |
| 32 | // Drag and drop zone |
| 33 | const dropZone = document.getElementById('drop-zone'); |
| 34 | |
| 35 | dropZone.addEventListener('dragover', (e) => { |
| 36 | e.preventDefault(); |
| 37 | dropZone.classList.add('drag-over'); |
| 38 | }); |
| 39 | |
| 40 | dropZone.addEventListener('dragleave', () => { |
| 41 | dropZone.classList.remove('drag-over'); |
| 42 | }); |
| 43 | |
| 44 | dropZone.addEventListener('drop', (e) => { |
| 45 | e.preventDefault(); |
| 46 | dropZone.classList.remove('drag-over'); |
| 47 | const files = e.dataTransfer.files; |
| 48 | handleFiles(files); |
| 49 | }); |
| 50 | |
| 51 | // Upload with progress |
| 52 | function uploadFile(file) { |
| 53 | const formData = new FormData(); |
| 54 | formData.append('file', file); |
| 55 | |
| 56 | const xhr = new XMLHttpRequest(); |
| 57 | |
| 58 | xhr.upload.addEventListener('progress', (e) => { |
| 59 | if (e.lengthComputable) { |
| 60 | const pct = Math.round((e.loaded / e.total) * 100); |
| 61 | progressBar.style.width = pct + '%'; |
| 62 | progressText.textContent = pct + '%'; |
| 63 | } |
| 64 | }); |
| 65 | |
| 66 | xhr.addEventListener('load', () => { |
| 67 | if (xhr.status === 200) { |
| 68 | showSuccess('Upload complete'); |
| 69 | } else { |
| 70 | showError('Upload failed'); |
| 71 | } |
| 72 | }); |
| 73 | |
| 74 | xhr.addEventListener('error', () => { |
| 75 | showError('Network error'); |
| 76 | }); |
| 77 | |
| 78 | xhr.open('POST', '/api/upload'); |
| 79 | xhr.send(formData); |
| 80 | } |
An autocomplete search box combines a text input with a suggestion dropdown. As the user types, debounced fetch requests retrieve suggestions. The dropdown is keyboard-navigable: arrow keys move through items, Enter selects, and Escape closes. Accessibility is critical — use role="combobox", aria-activedescendant, and role="listbox" so screen readers announce the results.
| 1 | class Autocomplete { |
| 2 | constructor(input, options = {}) { |
| 3 | this.input = input; |
| 4 | this.fetchFn = options.fetchFn; // (query) => Promise<items> |
| 5 | this.minChars = options.minChars || 2; |
| 6 | this.debounceMs = options.debounceMs || 300; |
| 7 | this.onSelect = options.onSelect; |
| 8 | this.listbox = null; |
| 9 | this.items = []; |
| 10 | this.activeIndex = -1; |
| 11 | |
| 12 | this.setupAccessibility(); |
| 13 | this.bindEvents(); |
| 14 | } |
| 15 | |
| 16 | setupAccessibility() { |
| 17 | this.listbox = document.createElement('ul'); |
| 18 | this.listbox.setAttribute('role', 'listbox'); |
| 19 | this.listbox.id = `${this.input.id}-listbox`; |
| 20 | this.listbox.style.display = 'none'; |
| 21 | this.input.setAttribute('role', 'combobox'); |
| 22 | this.input.setAttribute('aria-expanded', 'false'); |
| 23 | this.input.setAttribute('aria-autocomplete', 'list'); |
| 24 | this.input.setAttribute('aria-controls', this.listbox.id); |
| 25 | this.input.parentElement.appendChild(this.listbox); |
| 26 | } |
| 27 | |
| 28 | bindEvents() { |
| 29 | this.input.addEventListener('input', |
| 30 | debounce(() => this.onInput(), this.debounceMs)); |
| 31 | |
| 32 | this.input.addEventListener('keydown', (e) => { |
| 33 | switch (e.key) { |
| 34 | case 'ArrowDown': |
| 35 | e.preventDefault(); |
| 36 | this.activeIndex = Math.min( |
| 37 | this.activeIndex + 1, this.items.length - 1 |
| 38 | ); |
| 39 | this.highlight(); |
| 40 | break; |
| 41 | case 'ArrowUp': |
| 42 | e.preventDefault(); |
| 43 | this.activeIndex = Math.max( |
| 44 | this.activeIndex - 1, -1 |
| 45 | ); |
| 46 | this.highlight(); |
| 47 | break; |
| 48 | case 'Enter': |
| 49 | e.preventDefault(); |
| 50 | if (this.activeIndex >= 0) { |
| 51 | this.select(this.items[this.activeIndex]); |
| 52 | } |
| 53 | break; |
| 54 | case 'Escape': |
| 55 | this.close(); |
| 56 | break; |
| 57 | } |
| 58 | }); |
| 59 | |
| 60 | this.input.addEventListener('blur', () => { |
| 61 | setTimeout(() => this.close(), 150); |
| 62 | }); |
| 63 | } |
| 64 | |
| 65 | async onInput() { |
| 66 | const query = this.input.value.trim(); |
| 67 | if (query.length < this.minChars) { |
| 68 | this.close(); |
| 69 | return; |
| 70 | } |
| 71 | |
| 72 | this.items = await this.fetchFn(query); |
| 73 | this.render(); |
| 74 | } |
| 75 | |
| 76 | render() { |
| 77 | if (this.items.length === 0) { |
| 78 | this.close(); |
| 79 | return; |
| 80 | } |
| 81 | |
| 82 | this.listbox.innerHTML = this.items.map((item, i) => |
| 83 | `<li role="option" data-index="${i}" |
| 84 | class="autocomplete-item" |
| 85 | aria-selected="false">${item.label}</li>` |
| 86 | ).join(''); |
| 87 | |
| 88 | this.activeIndex = -1; |
| 89 | this.listbox.style.display = 'block'; |
| 90 | this.input.setAttribute('aria-expanded', 'true'); |
| 91 | } |
| 92 | |
| 93 | highlight() { |
| 94 | const items = this.listbox.querySelectorAll('li'); |
| 95 | items.forEach((el, i) => { |
| 96 | el.classList.toggle('active', i === this.activeIndex); |
| 97 | el.setAttribute('aria-selected', i === this.activeIndex); |
| 98 | }); |
| 99 | this.input.setAttribute( |
| 100 | 'aria-activedescendant', |
| 101 | this.activeIndex >= 0 |
| 102 | ? items[this.activeIndex]?.id || '' |
| 103 | : '' |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | select(item) { |
| 108 | this.input.value = item.label; |
| 109 | this.onSelect?.(item); |
| 110 | this.close(); |
| 111 | } |
| 112 | |
| 113 | close() { |
| 114 | this.listbox.style.display = 'none'; |
| 115 | this.input.setAttribute('aria-expanded', 'false'); |
| 116 | this.input.removeAttribute('aria-activedescendant'); |
| 117 | this.activeIndex = -1; |
| 118 | } |
| 119 | } |
best practice
Even experienced developers make these mistakes when handling forms. Here are the most common pitfalls and how to avoid them:
| Mistake | Why It Matters | Fix |
|---|---|---|
| Not preventing default submit | Page reloads and form data is lost | Call e.preventDefault() as first line of submit handler |
| Only validating on submit | User sees all errors at once, poor UX | Add real-time validation via input and blur events |
| Not disabling submit during request | Double submission, duplicate data | Set submitBtn.disabled = true during async request |
| Not showing loading state | User thinks nothing is happening, clicks again | Show spinner or "Sending..." text on the button |
| Ignoring keyboard users | Forms break for keyboard-only navigation | Handle Enter in inputs, Tab between fields, Escape to close dropdowns |
| Not resetting validation on form reset | Red borders and errors persist after reset | Clear error states and CSS classes in the reset event handler |
| Not handling the Enter key | Pressing Enter in an input does nothing | Ensure inputs are inside a <form> element; Enter triggers submit |
Use this checklist when building any form to ensure a robust, accessible, and user-friendly experience.
| Practice | Description | |
|---|---|---|
| ✓ | Progressive enhancement | Start with semantic HTML + native validation, layer JS on top |
| ✓ | Label every input | Use <label> elements with for attribute for accessibility |
| ✓ | Real-time validation | Validate on input with debounce and on blur immediately |
| ✓ | Clear error messages | Tell the user what is wrong and how to fix it, not just "Invalid input" |
| ✓ | Disable submit during request | Prevent double submission; show loading indicator |
| ✓ | Handle all states | Loading, empty, error, success, and edge cases |
| ✓ | Keyboard accessible | Tab order, Enter to submit, Escape for dropdowns, ARIA attributes |
| ✓ | Reset validation on form reset | Clear all error states, CSS classes, and messages |
| ✓ | Cross-field validation | Password confirmation, conditional required fields, date ranges |
| ✓ | Server-side validation too | Never trust client-side validation alone — always validate on the server |
| ✓ | Autocomplete attributes | Use autocomplete="email", "tel", "address-line1", etc. |
| ✓ | Loading state on buttons | Disable button text to "Sending..." and prevent further clicks |
| ✓ | FormData for structured data | Use new FormData(form) over manual field reading |
| ✓ | Preserve state on refresh | Save multi-step form data to sessionStorage for crash recovery |