Forms API
The Forms API is a collection of JavaScript interfaces and HTML attributes that make forms interactive, accessible, and easy to validate. It includes the FormData API for reading and manipulating form data, the Constraint Validation API for programmatic validation, and a rich set of form-related events.
Understanding the Forms API lets you build forms that work without JavaScript (progressive enhancement), provide rich client-side validation, and integrate cleanly with server-side processing. This guide covers every major interface with practical examples.
The FormData interface provides a way to construct a set of key-value pairs representing form fields and their values, which can be sent using fetch() or XMLHttpRequest. It automatically handles encoding using the form's enctype attribute:
| 1 | <form id="registration-form"> |
| 2 | <input type="text" name="username" required> |
| 3 | <input type="email" name="email" required> |
| 4 | <input type="password" name="password" required> |
| 5 | <select name="role"> |
| 6 | <option value="user">User</option> |
| 7 | <option value="admin">Admin</option> |
| 8 | </select> |
| 9 | <input type="file" name="avatar" accept="image/*"> |
| 10 | <button type="submit">Register</button> |
| 11 | </form> |
| 12 | |
| 13 | <script> |
| 14 | const form = document.getElementById('registration-form'); |
| 15 | |
| 16 | form.addEventListener('submit', async (event) => { |
| 17 | event.preventDefault(); |
| 18 | |
| 19 | // Create FormData from the form element |
| 20 | const formData = new FormData(form); |
| 21 | |
| 22 | // Log all entries |
| 23 | for (const [key, value] of formData.entries()) { |
| 24 | console.log(key + ':', value); |
| 25 | } |
| 26 | |
| 27 | // FormData is automatically encoded as multipart/form-data |
| 28 | // when sent via fetch |
| 29 | const response = await fetch('/api/register', { |
| 30 | method: 'POST', |
| 31 | body: formData, |
| 32 | // Do NOT set Content-Type header -- the browser sets it |
| 33 | // automatically with the correct boundary |
| 34 | }); |
| 35 | |
| 36 | const result = await response.json(); |
| 37 | console.log('Registration:', result); |
| 38 | }); |
| 39 | </script> |
FormData provides several methods for programmatic manipulation:
| 1 | // Creating FormData in different ways |
| 2 | |
| 3 | // From a form element |
| 4 | const form = document.querySelector('form'); |
| 5 | const fd1 = new FormData(form); |
| 6 | |
| 7 | // From scratch (no form needed) |
| 8 | const fd2 = new FormData(); |
| 9 | fd2.append('name', 'Jane'); |
| 10 | fd2.append('age', '30'); |
| 11 | fd2.append('interests', 'HTML'); |
| 12 | fd2.append('interests', 'CSS'); // multiple values for same key |
| 13 | |
| 14 | // Methods available on FormData |
| 15 | fd2.append('key', 'value'); // Add a new field |
| 16 | fd2.set('key', 'value'); // Set field (replaces existing) |
| 17 | fd2.delete('key'); // Remove a field |
| 18 | fd2.has('key'); // Check if field exists |
| 19 | fd2.get('key'); // Get first value for key |
| 20 | fd2.getAll('key'); // Get all values for key |
| 21 | |
| 22 | // Iteration |
| 23 | for (const [key, value] of fd2.entries()) { |
| 24 | console.log(key, value); |
| 25 | } |
| 26 | fd2.forEach((value, key) => { |
| 27 | console.log(key, value); |
| 28 | }); |
| 29 | |
| 30 | // Converting to URL-encoded string |
| 31 | const params = new URLSearchParams(fd2); |
| 32 | console.log(params.toString()); |
| 33 | // "name=Jane&age=30&interests=HTML&interests=CSS" |
| 34 | |
| 35 | // Converting to plain object |
| 36 | const obj = Object.fromEntries(fd2.entries()); |
| 37 | console.log(obj); |
| 38 | // { name: "Jane", age: "30", interests: "CSS" } |
| 39 | // Note: only last value per key with Object.fromEntries |
info
The most common use of FormData is sending form data via fetch. Here is a complete pattern including file uploads and error handling:
| 1 | <form id="upload-form" enctype="multipart/form-data"> |
| 2 | <input type="text" name="title" required> |
| 3 | <textarea name="description"></textarea> |
| 4 | <input type="file" name="file" accept=".pdf,.doc,.docx" required> |
| 5 | <input type="hidden" name="csrf_token" value="abc123"> |
| 6 | <button type="submit" id="submit-btn">Upload</button> |
| 7 | <div id="status"></div> |
| 8 | </form> |
| 9 | |
| 10 | <script> |
| 11 | const form = document.getElementById('upload-form'); |
| 12 | const status = document.getElementById('status'); |
| 13 | |
| 14 | form.addEventListener('submit', async (e) => { |
| 15 | e.preventDefault(); |
| 16 | const btn = document.getElementById('submit-btn'); |
| 17 | btn.disabled = true; |
| 18 | btn.textContent = 'Uploading...'; |
| 19 | |
| 20 | const formData = new FormData(form); |
| 21 | |
| 22 | // Log form data contents |
| 23 | console.log('Files:', formData.getAll('file')); |
| 24 | console.log('Title:', formData.get('title')); |
| 25 | |
| 26 | try { |
| 27 | const response = await fetch('/api/upload', { |
| 28 | method: 'POST', |
| 29 | body: formData, |
| 30 | }); |
| 31 | |
| 32 | if (!response.ok) { |
| 33 | const error = await response.json(); |
| 34 | throw new Error(error.message || 'Upload failed'); |
| 35 | } |
| 36 | |
| 37 | const result = await response.json(); |
| 38 | status.textContent = 'Upload successful: ' + result.url; |
| 39 | status.style.color = '#00FF41'; |
| 40 | form.reset(); |
| 41 | } catch (error) { |
| 42 | status.textContent = 'Error: ' + error.message; |
| 43 | status.style.color = '#FF3355'; |
| 44 | } finally { |
| 45 | btn.disabled = false; |
| 46 | btn.textContent = 'Upload'; |
| 47 | } |
| 48 | }); |
| 49 | </script> |
best practice
The Constraint Validation API lets you check and customize form field validation programmatically. Every form element has a validity property that returns a ValidityState object with boolean flags for each possible constraint failure:
| 1 | <form id="validated-form" novalidate> |
| 2 | <input type="email" name="email" id="email-input" |
| 3 | required minlength="5" maxlength="254" |
| 4 | placeholder="user@example.com"> |
| 5 | <span id="email-error" class="error"></span> |
| 6 | |
| 7 | <input type="url" name="website" id="website-input" |
| 8 | required pattern="https?://.+" |
| 9 | placeholder="https://example.com"> |
| 10 | <span id="website-error" class="error"></span> |
| 11 | |
| 12 | <input type="number" name="age" id="age-input" |
| 13 | required min="18" max="120"> |
| 14 | <span id="age-error" class="error"></span> |
| 15 | |
| 16 | <button type="submit">Submit</button> |
| 17 | </form> |
| 18 | |
| 19 | <script> |
| 20 | const form = document.getElementById('validated-form'); |
| 21 | const emailInput = document.getElementById('email-input'); |
| 22 | |
| 23 | // Check validity programmatically |
| 24 | console.log(emailInput.validity); |
| 25 | // ValidityState { |
| 26 | // valueMissing, typeMismatch, patternMismatch, |
| 27 | // tooLong, tooShort, rangeUnderflow, rangeOverflow, |
| 28 | // stepMismatch, badInput, customError, valid |
| 29 | // } |
| 30 | |
| 31 | // Check if field is valid |
| 32 | console.log(emailInput.checkValidity()); // true or false |
| 33 | |
| 34 | // Get validation message |
| 35 | console.log(emailInput.validationMessage); // "" or constraint msg |
| 36 | |
| 37 | // Custom validation messages |
| 38 | emailInput.addEventListener('input', () => { |
| 39 | if (emailInput.validity.valueMissing) { |
| 40 | emailInput.setCustomValidity('Email address is required'); |
| 41 | } else if (emailInput.validity.typeMismatch) { |
| 42 | emailInput.setCustomValidity('Please enter a valid email'); |
| 43 | } else if (emailInput.validity.tooShort) { |
| 44 | emailInput.setCustomValidity('Email must be at least 5 characters'); |
| 45 | } else { |
| 46 | emailInput.setCustomValidity(''); // Clear custom error |
| 47 | } |
| 48 | }); |
| 49 | |
| 50 | // Submit handler with full validation |
| 51 | form.addEventListener('submit', (e) => { |
| 52 | e.preventDefault(); |
| 53 | const fields = form.querySelectorAll('input, textarea, select'); |
| 54 | let isValid = true; |
| 55 | |
| 56 | fields.forEach(field => { |
| 57 | if (!field.checkValidity()) { |
| 58 | isValid = false; |
| 59 | field.classList.add('invalid'); |
| 60 | const errorEl = document.getElementById(field.id + '-error'); |
| 61 | if (errorEl) errorEl.textContent = field.validationMessage; |
| 62 | } else { |
| 63 | field.classList.remove('invalid'); |
| 64 | const errorEl = document.getElementById(field.id + '-error'); |
| 65 | if (errorEl) errorEl.textContent = ''; |
| 66 | } |
| 67 | }); |
| 68 | |
| 69 | if (isValid) form.submit(); |
| 70 | }); |
| 71 | </script> |
The ValidityState object has boolean properties that describe exactly why a field failed validation:
| Property | Description | Triggered By |
|---|---|---|
| valueMissing | Field is required but empty | required attribute |
| typeMismatch | Value does not match expected type | type="email" or type="url" |
| patternMismatch | Value does not match the pattern | pattern attribute (regex) |
| tooLong | Value exceeds maxlength | maxlength attribute |
| tooShort | Value is shorter than minlength | minlength attribute |
| rangeUnderflow | Number is below minimum | min attribute |
| rangeOverflow | Number exceeds maximum | max attribute |
| stepMismatch | Number does not align with step | step attribute |
| badInput | Text entered in number/date field | type="number" etc. |
| customError | Custom error via setCustomValidity | setCustomValidity() |
| valid | All constraints satisfied | All other properties are false |
| 1 | // Detailed validity checking |
| 2 | const input = document.querySelector('input[type="email"]'); |
| 3 | |
| 4 | function getValidationError(field) { |
| 5 | const v = field.validity; |
| 6 | |
| 7 | if (v.valueMissing) return 'This field is required.'; |
| 8 | if (v.typeMismatch) return 'Please enter a valid ' + field.type + '.'; |
| 9 | if (v.patternMismatch) return 'Value does not match the required format.'; |
| 10 | if (v.tooLong) return 'Maximum length is ' + field.maxLength + '.'; |
| 11 | if (v.tooShort) return 'Minimum length is ' + field.minLength + '.'; |
| 12 | if (v.rangeUnderflow) return 'Minimum value is ' + field.min + '.'; |
| 13 | if (v.rangeOverflow) return 'Maximum value is ' + field.max + '.'; |
| 14 | if (v.stepMismatch) return 'Value must be in steps of ' + field.step + '.'; |
| 15 | if (v.badInput) return 'Please enter a valid value.'; |
| 16 | if (v.customError) return field.validationMessage; |
| 17 | |
| 18 | return null; // Field is valid |
| 19 | } |
| 20 | |
| 21 | // Real-time validation on every input |
| 22 | document.querySelectorAll('input, textarea').forEach(field => { |
| 23 | field.addEventListener('input', () => { |
| 24 | const error = getValidationError(field); |
| 25 | const errorEl = field.nextElementSibling; |
| 26 | |
| 27 | if (error) { |
| 28 | field.setCustomValidity(error); |
| 29 | if (errorEl) errorEl.textContent = error; |
| 30 | field.setAttribute('aria-invalid', 'true'); |
| 31 | } else { |
| 32 | field.setCustomValidity(''); |
| 33 | if (errorEl) errorEl.textContent = ''; |
| 34 | field.removeAttribute('aria-invalid'); |
| 35 | } |
| 36 | }); |
| 37 | }); |
The setCustomValidity()method lets you override the browser's default error messages. Passing an empty string clears the custom error. This is useful for complex rules that cannot be expressed with HTML attributes alone:
| 1 | <form id="signup-form" novalidate> |
| 2 | <input type="password" id="password" name="password" |
| 3 | required minlength="8"> |
| 4 | <span class="error" id="password-error"></span> |
| 5 | |
| 6 | <input type="password" id="confirm-password" |
| 7 | name="confirm-password" required> |
| 8 | <span class="error" id="confirm-error"></span> |
| 9 | |
| 10 | <input type="text" id="username" name="username" |
| 11 | required minlength="3" maxlength="20"> |
| 12 | <span class="error" id="username-error"></span> |
| 13 | |
| 14 | <button type="submit">Sign Up</button> |
| 15 | </form> |
| 16 | |
| 17 | <script> |
| 18 | const form = document.getElementById('signup-form'); |
| 19 | const password = document.getElementById('password'); |
| 20 | const confirmPassword = document.getElementById('confirm-password'); |
| 21 | const username = document.getElementById('username'); |
| 22 | |
| 23 | // Custom validation: passwords must match |
| 24 | confirmPassword.addEventListener('input', () => { |
| 25 | if (confirmPassword.value !== password.value) { |
| 26 | confirmPassword.setCustomValidity('Passwords do not match'); |
| 27 | document.getElementById('confirm-error').textContent = |
| 28 | 'Passwords do not match'; |
| 29 | } else { |
| 30 | confirmPassword.setCustomValidity(''); |
| 31 | document.getElementById('confirm-error').textContent = ''; |
| 32 | } |
| 33 | }); |
| 34 | |
| 35 | // Custom validation: password strength |
| 36 | password.addEventListener('input', () => { |
| 37 | const value = password.value; |
| 38 | const hasUpper = /[A-Z]/.test(value); |
| 39 | const hasLower = /[a-z]/.test(value); |
| 40 | const hasNumber = /[0-9]/.test(value); |
| 41 | const hasSpecial = /[!@#$%^&*]/.test(value); |
| 42 | |
| 43 | if (value.length < 8) { |
| 44 | password.setCustomValidity('Must be at least 8 characters'); |
| 45 | } else if (!(hasUpper && hasLower)) { |
| 46 | password.setCustomValidity('Must contain upper and lowercase letters'); |
| 47 | } else if (!hasNumber) { |
| 48 | password.setCustomValidity('Must contain at least one number'); |
| 49 | } else if (!hasSpecial) { |
| 50 | password.setCustomValidity('Must contain a special character'); |
| 51 | } else { |
| 52 | password.setCustomValidity(''); |
| 53 | } |
| 54 | document.getElementById('password-error').textContent = |
| 55 | password.validationMessage; |
| 56 | }); |
| 57 | |
| 58 | // Custom validation: username format |
| 59 | username.addEventListener('input', () => { |
| 60 | if (/^[a-zA-Z0-9_]+$/.test(username.value) || username.value === '') { |
| 61 | username.setCustomValidity(''); |
| 62 | } else { |
| 63 | username.setCustomValidity( |
| 64 | 'Only letters, numbers, and underscores allowed' |
| 65 | ); |
| 66 | } |
| 67 | document.getElementById('username-error').textContent = |
| 68 | username.validationMessage; |
| 69 | }); |
| 70 | |
| 71 | form.addEventListener('submit', (e) => { |
| 72 | e.preventDefault(); |
| 73 | if (form.checkValidity()) { |
| 74 | console.log('Form is valid! Submitting...'); |
| 75 | } else { |
| 76 | console.log('Form has validation errors.'); |
| 77 | } |
| 78 | }); |
| 79 | </script> |
warning
Forms fire several events that let you respond to user interactions. The key events are submit, input, change, invalid, and reset:
| 1 | <form id="event-demo" novalidate> |
| 2 | <input type="email" name="email" required> |
| 3 | <input type="text" name="username" required minlength="3"> |
| 4 | <button type="submit">Submit</button> |
| 5 | <button type="reset">Reset</button> |
| 6 | </form> |
| 7 | |
| 8 | <div id="event-log"></div> |
| 9 | |
| 10 | <script> |
| 11 | const form = document.getElementById('event-demo'); |
| 12 | const log = document.getElementById('event-log'); |
| 13 | |
| 14 | function addLog(message) { |
| 15 | const entry = document.createElement('div'); |
| 16 | entry.textContent = new Date().toLocaleTimeString() |
| 17 | + ': ' + message; |
| 18 | log.appendChild(entry); |
| 19 | } |
| 20 | |
| 21 | // submit: fires when form is submitted |
| 22 | form.addEventListener('submit', (e) => { |
| 23 | e.preventDefault(); |
| 24 | addLog('SUBMIT: Form submitted'); |
| 25 | // Validate, then call form.submit() or send via fetch |
| 26 | }); |
| 27 | |
| 28 | // input: fires on every keystroke/change in any field |
| 29 | form.addEventListener('input', (e) => { |
| 30 | const field = e.target; |
| 31 | addLog('INPUT: ' + field.name + ' = "' + field.value + '"'); |
| 32 | // Real-time validation |
| 33 | if (field.checkValidity()) { |
| 34 | field.style.borderColor = '#00FF41'; |
| 35 | } else { |
| 36 | field.style.borderColor = '#FF3355'; |
| 37 | } |
| 38 | }); |
| 39 | |
| 40 | // change: fires when field loses focus after value changed |
| 41 | form.addEventListener('change', (e) => { |
| 42 | addLog('CHANGE: ' + e.target.name + ' was updated'); |
| 43 | }); |
| 44 | |
| 45 | // invalid: fires when field fails constraint validation |
| 46 | form.addEventListener('invalid', (e) => { |
| 47 | e.preventDefault(); // Prevent default browser tooltip |
| 48 | const field = e.target; |
| 49 | addLog('INVALID: ' + field.name |
| 50 | + ' -- ' + field.validationMessage); |
| 51 | field.style.borderColor = '#FF3355'; |
| 52 | }, true); // Capture phase to catch all invalid events |
| 53 | |
| 54 | // reset: fires when form is reset |
| 55 | form.addEventListener('reset', () => { |
| 56 | addLog('RESET: Form fields cleared'); |
| 57 | form.querySelectorAll('input').forEach(f => { |
| 58 | f.style.borderColor = ''; |
| 59 | }); |
| 60 | }); |
| 61 | </script> |
| Event | Target | When It Fires |
|---|---|---|
| submit | form | User clicks submit or presses Enter |
| input | input, textarea, select | Every keystroke, paste, or value change |
| change | input, textarea, select | Field loses focus after value was changed |
| invalid | form elements | Field fails constraint validation |
| reset | form | User clicks reset or calls form.reset() |
| focusin / focusout | form elements | Field gains or loses focus (bubbles) |
Progressive enhancement means forms work without JavaScript and become richer when JS is available. HTML5 validation attributes provide the baseline, while the Forms API layers on top for enhanced experiences:
| 1 | <!-- Baseline: works without JavaScript --> |
| 2 | <form action="/api/register" method="POST"> |
| 3 | <input type="text" name="username" required minlength="3" |
| 4 | maxlength="20" pattern="[a-zA-Z0-9_]+" |
| 5 | placeholder="Username"> |
| 6 | |
| 7 | <input type="email" name="email" required |
| 8 | placeholder="user@example.com"> |
| 9 | |
| 10 | <input type="password" name="password" required minlength="8" |
| 11 | placeholder="Password (min 8 characters)"> |
| 12 | |
| 13 | <button type="submit">Register</button> |
| 14 | </form> |
| 15 | |
| 16 | <!-- Enhancement: with JavaScript for richer UX --> |
| 17 | <script> |
| 18 | const form = document.querySelector('form'); |
| 19 | |
| 20 | // Feature detection: check for Constraint Validation API |
| 21 | if ('validity' in document.createElement('input')) { |
| 22 | form.setAttribute('novalidate', ''); |
| 23 | |
| 24 | form.addEventListener('input', (e) => { |
| 25 | const field = e.target; |
| 26 | if (field.validity.valid) { |
| 27 | field.style.borderColor = '#00FF41'; |
| 28 | } else { |
| 29 | field.style.borderColor = '#FF3355'; |
| 30 | } |
| 31 | }); |
| 32 | |
| 33 | form.addEventListener('submit', async (e) => { |
| 34 | e.preventDefault(); |
| 35 | |
| 36 | if (!form.checkValidity()) { |
| 37 | const firstInvalid = form.querySelector(':invalid'); |
| 38 | firstInvalid.focus(); |
| 39 | return; |
| 40 | } |
| 41 | |
| 42 | // Submit via fetch instead of page reload |
| 43 | const formData = new FormData(form); |
| 44 | try { |
| 45 | const response = await fetch('/api/register', { |
| 46 | method: 'POST', |
| 47 | body: formData, |
| 48 | }); |
| 49 | |
| 50 | if (response.ok) { |
| 51 | window.location.href = '/welcome'; |
| 52 | } else { |
| 53 | const error = await response.json(); |
| 54 | alert(error.message); |
| 55 | } |
| 56 | } catch { |
| 57 | // Fallback: submit traditionally |
| 58 | form.submit(); |
| 59 | } |
| 60 | }); |
| 61 | } |
| 62 | // Without Constraint Validation API, |
| 63 | // form submits normally via action URL |
| 64 | </script> |
best practice
This demo demonstrates real-time validation and custom error messages in action:
pro tip