|$ curl https://forge-ai.dev/api/markdown?path=docs/js/form-handling
$cat docs/javascript-—-form-handling.md
updated Recently·45 min read·published

JavaScript — Form Handling

JavaScriptFormsValidationInteractiveIntermediate🎯Free Tools
Introduction

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.

index.html
HTML
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 -->
Accessing Form Elements

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.

accessing-elements.js
JavaScript
1// Access forms by index or name
2const form1 = document.forms[0];
3const loginForm = document.forms.login; // <form name="login">
4
5// Access form elements
6const username = loginForm.elements.username;
7const email = loginForm.elements.email;
8const submit = loginForm.elements.submit;
9
10// Query selectors also work
11const form = document.querySelector('#signup-form');
12const inputs = form.querySelectorAll('input, select, textarea');
13
14// Named access — input name becomes property
15const form = document.forms.checkout;
16console.log(form.email.value); // direct property access
17console.log(form['shipping-address'].value); // bracket for hyphens
18
19// The FormData API — modern structured access
20const data = new FormData(form);
21console.log(data.get('email')); // "user@example.com"
22console.log(data.getAll('hobbies')); // ["reading", "coding"]
preview

info

Named access via form.elementName is convenient but can conflict with the form's own properties (submit, reset, length). The FormData API avoids these collisions entirely and is recommended for modern code.
Reading Form Values

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.

reading-values.js
JavaScript
1// Text / email / number / password
2const textValue = input.value;
3
4// Checkbox — checked state, not value
5const isChecked = checkbox.checked;
6const checkboxValue = checkbox.value; // submitted value
7
8// Radio buttons — find checked in group
9const radios = form.elements.gender; // RadioNodeList
10let selectedGender;
11for (const radio of radios) {
12 if (radio.checked) {
13 selectedGender = radio.value;
14 break;
15 }
16}
17// Or spread + find
18const gender = [...form.elements.gender]
19 .find(r => r.checked)?.value;
20
21// Select (single)
22const selectedValue = select.value;
23const selectedOption = select.options[select.selectedIndex];
24const optionText = selectedOption.textContent;
25
26// Select (multiple)
27const selectedValues = [...select.selectedOptions]
28 .map(opt => opt.value);
29
30// Textarea — same as text input
31const bio = textarea.value;
32
33// File input
34const files = fileInput.files; // FileList
35if (files.length > 0) {
36 const file = files[0];
37 console.log(file.name, file.size, file.type);
38}
preview
Setting Form Values

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).

setting-values.js
JavaScript
1// Setting values by control
2input.value = 'hello@example.com';
3checkbox.checked = true;
4radio.checked = true;
5select.value = 'uk';
6textarea.value = 'Some pre-filled text';
7
8// Trigger events after setting (so listeners fire)
9input.dispatchEvent(new Event('input', { bubbles: true }));
10input.dispatchEvent(new Event('change', { bubbles: true }));
11
12// Select option by index
13select.selectedIndex = 2;
14
15// Check a specific radio in a group
16form.elements.gender.value = 'female'; // sets checked state
17
18// Setting multiple values at once
19function 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
33setFormValues(form, {
34 email: 'user@example.com',
35 subscribe: true,
36 plan: 'premium'
37});
38
39// Reset form to defaults
40form.reset(); // restores HTML defaults
preview
Form Events

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.

form-events.js
JavaScript
1form.addEventListener('submit', (e) => {
2 e.preventDefault(); // stop browser submission
3 console.log('Form submitted');
4 // handle with fetch / XHR
5});
6
7input.addEventListener('input', (e) => {
8 console.log('Value changed:', e.target.value);
9 // real-time validation here
10});
11
12input.addEventListener('change', (e) => {
13 console.log('User committed change:', e.target.value);
14 // fires on blur for text, immediately for checkbox/radio
15});
16
17input.addEventListener('focus', (e) => {
18 console.log('Focused on:', e.target.name);
19 // show hint, highlight field
20});
21
22input.addEventListener('blur', (e) => {
23 console.log('Left field:', e.target.name);
24 // validate on field exit
25});
26
27form.addEventListener('reset', (e) => {
28 console.log('Form was reset');
29 // clear validation state
30});
31
32input.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});
preview
Real-Time Validation

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.

real-time-validation.js
JavaScript
1// Debounce helper — validates after user stops typing
2function 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
11const emailInput = document.getElementById('email');
12const emailError = document.getElementById('email-error');
13
14const 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
28function showError(message) {
29 emailError.textContent = message;
30 emailError.style.display = 'block';
31 emailInput.style.borderColor = '#FF4444';
32}
33
34function clearError() {
35 emailError.textContent = '';
36 emailError.style.display = 'none';
37 emailInput.style.borderColor = '#2A2A3E';
38}
39
40emailInput.addEventListener('input', validateEmail);
41emailInput.addEventListener('blur', () => {
42 // Validate immediately on blur (no debounce)
43 validateEmail.flush?.();
44});
45
46// Validate on form submit too
47form.addEventListener('submit', (e) => {
48 if (!emailInput.validity.valid) {
49 e.preventDefault();
50 showError(emailInput.validationMessage);
51 emailInput.focus();
52 }
53});
preview

best practice

Use debounced validation for input events (250-300ms delay) and immediate validation for blur events. This gives fast feedback without validating on every keystroke, while still catching errors when the user leaves a field.
Constraint Validation API

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.

constraint-validation.js
JavaScript
1// Check individual field validity
2const input = document.getElementById('email');
3if (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
24if (form.checkValidity()) {
25 // all fields pass
26} else {
27 // at least one field is invalid
28}
29
30// Show native validation bubbles
31form.reportValidity();
32
33// Custom validation message
34const password = document.getElementById('password');
35if (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
45console.log(password.validity.customError);
preview
📝

note

The setCustomValidity('') call with an empty string clears the custom error. Any non-empty string sets validity.customError to true and makes the field invalid. Use this for cross-field validation (e.g., "passwords must match").
Building a Form Validator

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.

form-validator.js
JavaScript
1class 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
109const validator = new FormValidator(form, {
110 errorClass: 'border-red',
111 validClass: 'border-green'
112});
113
114validator.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
121validator.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
127form.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});
preview
Form Submission with Fetch

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).

form-submit-fetch.js
JavaScript
1form.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});
preview

warning

When sending FormData directly, do NOT set the Content-Type header yourself — the browser will set it to multipart/form-data with the correct boundary string. Setting it manually will break file uploads and may corrupt the request body.
Multi-Step Forms

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.

multi-step.js
JavaScript
1class 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}
preview
🔥

pro tip

Store multi-step form data in a single object in sessionStorage so the user can refresh without losing progress. On page load, restore values from the stored data. Clear storage only on successful final submission.
Dynamic Forms

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.

dynamic-forms.js
JavaScript
1// Add / remove fields dynamically
2const container = document.getElementById('phones-container');
3let phoneCount = 1;
4
5function 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
19const hasCompany = document.getElementById('has_company');
20const companyFields = document.getElementById('company-fields');
21
22hasCompany.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
33const countrySelect = document.getElementById('country');
34const stateSelect = document.getElementById('state');
35const citySelect = document.getElementById('city');
36
37countrySelect.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
48stateSelect.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});
preview
File Upload

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.

file-upload.js
JavaScript
1// File validation
2const fileInput = document.getElementById('avatar');
3const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
4const maxSize = 5 * 1024 * 1024; // 5MB
5
6fileInput.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
33const dropZone = document.getElementById('drop-zone');
34
35dropZone.addEventListener('dragover', (e) => {
36 e.preventDefault();
37 dropZone.classList.add('drag-over');
38});
39
40dropZone.addEventListener('dragleave', () => {
41 dropZone.classList.remove('drag-over');
42});
43
44dropZone.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
52function 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}
preview
Search & Autocomplete

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.

autocomplete.js
JavaScript
1class 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}
preview

best practice

Always debounce autocomplete requests (250-400ms) to avoid flooding the server. Cache recent results in a Map keyed by query string to skip network requests for previously typed terms. Consider aborting in-flight requests when the user types a new character using AbortController.
Common Mistakes

Even experienced developers make these mistakes when handling forms. Here are the most common pitfalls and how to avoid them:

MistakeWhy It MattersFix
Not preventing default submitPage reloads and form data is lostCall e.preventDefault() as first line of submit handler
Only validating on submitUser sees all errors at once, poor UXAdd real-time validation via input and blur events
Not disabling submit during requestDouble submission, duplicate dataSet submitBtn.disabled = true during async request
Not showing loading stateUser thinks nothing is happening, clicks againShow spinner or "Sending..." text on the button
Ignoring keyboard usersForms break for keyboard-only navigationHandle Enter in inputs, Tab between fields, Escape to close dropdowns
Not resetting validation on form resetRed borders and errors persist after resetClear error states and CSS classes in the reset event handler
Not handling the Enter keyPressing Enter in an input does nothingEnsure inputs are inside a <form> element; Enter triggers submit
Best Practices Checklist

Use this checklist when building any form to ensure a robust, accessible, and user-friendly experience.

PracticeDescription
Progressive enhancementStart with semantic HTML + native validation, layer JS on top
Label every inputUse <label> elements with for attribute for accessibility
Real-time validationValidate on input with debounce and on blur immediately
Clear error messagesTell the user what is wrong and how to fix it, not just "Invalid input"
Disable submit during requestPrevent double submission; show loading indicator
Handle all statesLoading, empty, error, success, and edge cases
Keyboard accessibleTab order, Enter to submit, Escape for dropdowns, ARIA attributes
Reset validation on form resetClear all error states, CSS classes, and messages
Cross-field validationPassword confirmation, conditional required fields, date ranges
Server-side validation tooNever trust client-side validation alone — always validate on the server
Autocomplete attributesUse autocomplete="email", "tel", "address-line1", etc.
Loading state on buttonsDisable button text to "Sending..." and prevent further clicks
FormData for structured dataUse new FormData(form) over manual field reading
Preserve state on refreshSave multi-step form data to sessionStorage for crash recovery
$Blueprint — Engineering Documentation·Section ID: JS-FORM-HANDLING·Revision: 1.0