Forms are the primary way users provide data to web applications. Getting form handling right means more than preventing a page reload on submit — it means giving users immediate, clear feedback, managing submit state to prevent double-submissions, and validating on both the client and server. This article covers the complete picture from reading form values to building complex multi-step forms.
The Submit Event and Preventing Default
The submit event fires when a user submits a form — pressing Enter in an input, clicking a submit button, or programmatically calling form.submit(). The default browser behavior is a full page navigation, which you almost always want to prevent in modern applications:
const form = document.getElementById('loginForm');
form.addEventListener('submit', async (event) => {
event.preventDefault(); // Stop the browser from navigating away
const email = form.elements['email'].value.trim();
const password = form.elements['password'].value;
if (!email || !password) {
showError('Please fill in all fields');
return;
}
await submitLogin(email, password);
});
form.elements gives you access to all form controls by name — more reliable than querying by ID, because it works regardless of where in the DOM the control lives.
Collecting Data with FormData
FormData is the browser API for reading all form fields at once. It handles text inputs, checkboxes, radio buttons, select elements, and file inputs uniformly:
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
// data is { email: '...', password: '...', rememberMe: 'on', ... }
// Note: unchecked checkboxes are absent from FormData entirely
console.log(data);
});
For multi-value fields like checkboxes with the same name, Object.fromEntries keeps only the last value. Use formData.getAll(name) for multi-selects and checkbox groups:
const formData = new FormData(form);
const roles = formData.getAll('roles'); // ['editor', 'publisher']
You can also construct FormData programmatically and send it directly to fetch — the browser sets the correct multipart/form-data Content-Type header automatically, which is required for file uploads:
async function submitForm(form) {
const formData = new FormData(form);
const response = await fetch('/api/profile', {
method: 'POST',
body: formData, // Don't set Content-Type — browser handles it for FormData
});
return response.json();
}
Form Events Beyond Submit
Understanding when each event fires helps you build the right UX:
input— fires on every keystroke; use for real-time character count or immediate feedbackchange— fires when focus leaves and the value has changed; use for validation on blurfocus/blur— fires when a field gains or loses focus; use for showing helper text or validating
const emailInput = document.getElementById('email');
const emailError = document.getElementById('emailError');
// Validate only when user leaves the field (less disruptive than validating on every keystroke)
emailInput.addEventListener('blur', (event) => {
const value = event.target.value.trim();
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
emailError.textContent = 'Enter a valid email address';
emailInput.setAttribute('aria-invalid', 'true');
} else {
emailError.textContent = '';
emailInput.removeAttribute('aria-invalid');
}
});
// Clear error as soon as user starts correcting
emailInput.addEventListener('input', () => {
if (emailInput.getAttribute('aria-invalid')) {
emailError.textContent = '';
emailInput.removeAttribute('aria-invalid');
}
});
The aria-invalid attribute communicates validation state to screen readers — always update it alongside visible error messages.
Validation Strategy
Client-side validation provides immediate feedback. Server-side validation is the only reliable protection. You need both.
A reusable validation approach — define rules as data, apply them uniformly:
function validateField(value, rules) {
for (const rule of rules) {
const error = rule(value);
if (error) return error;
}
return null; // null means valid
}
// Composable validator functions
const required = (msg = 'This field is required') => v => !v?.trim() ? msg : null;
const minLength = (n, msg) => v => v.length < n ? (msg ?? `Minimum ${n} characters`) : null;
const maxLength = (n, msg) => v => v.length > n ? (msg ?? `Maximum ${n} characters`) : null;
const isEmail = (msg = 'Enter a valid email') => v =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : msg;
const matches = (other, msg = 'Values do not match') => (v, formData) =>
v !== formData[other] ? msg : null;
// Define rules for each field
const fieldRules = {
username: [required(), minLength(3), maxLength(20)],
email: [required(), isEmail()],
password: [required(), minLength(8)],
};
function validateForm(formData) {
const errors = {};
for (const [field, rules] of Object.entries(fieldRules)) {
const error = validateField(formData[field] ?? '', rules);
if (error) errors[field] = error;
}
return errors; // empty object means all valid
}
Apply validation on submit and display all errors at once:
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
const errors = validateForm(data);
// Clear previous errors
form.querySelectorAll('[data-error]').forEach(el => el.textContent = '');
if (Object.keys(errors).length > 0) {
Object.entries(errors).forEach(([field, message]) => {
const errorEl = form.querySelector(`[data-error="${field}"]`);
if (errorEl) errorEl.textContent = message;
});
// Focus the first invalid field for accessibility
const firstError = Object.keys(errors)[0];
form.elements[firstError]?.focus();
return;
}
await submitForm(data);
});
Managing Submit State
Double-submitting (clicking Submit while a request is in progress) is a common bug. Disable the submit button during the request and restore it afterward:
async function submitWithState(form, submitFn) {
const submitBtn = form.querySelector('[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
try {
await submitFn(new FormData(form));
showSuccess('Saved successfully');
} catch (err) {
showError(err.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
}
The finally block ensures the button is re-enabled even if the request fails.
File Upload with Validation
File inputs need client-side validation for size and type before uploading — catching these early avoids an unnecessary round trip:
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);
const MAX_SIZE_MB = 5;
async function uploadFile(fileInput) {
const file = fileInput.files[0];
if (!file) {
showError('Please select a file');
return;
}
if (!ALLOWED_TYPES.has(file.type)) {
showError('Only JPEG, PNG, WebP, and PDF files are allowed');
return;
}
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
showError(`File must be smaller than ${MAX_SIZE_MB}MB`);
return;
}
// Show a local preview for images before uploading
if (file.type.startsWith('image/')) {
const preview = document.getElementById('preview');
preview.src = URL.createObjectURL(file);
// Release the object URL after the image loads to free memory
preview.onload = () => URL.revokeObjectURL(preview.src);
}
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', { method: 'POST', body: formData });
const result = await response.json();
return result.url;
}
Multi-Step Forms
Multi-step forms break a long form into sections, reducing cognitive load. The key is keeping all data in one place and only submitting at the final step:
class MultiStepForm {
constructor(formId) {
this.form = document.getElementById(formId);
this.steps = Array.from(this.form.querySelectorAll('[data-step]'));
this.current = 0;
this.form.querySelector('[data-action="next"]').addEventListener('click', () => this.next());
this.form.querySelector('[data-action="prev"]').addEventListener('click', () => this.prev());
this.form.addEventListener('submit', e => this.submit(e));
this.render();
}
render() {
this.steps.forEach((step, i) => {
step.hidden = i !== this.current;
});
const isLast = this.current === this.steps.length - 1;
this.form.querySelector('[data-action="next"]').hidden = isLast;
this.form.querySelector('[type="submit"]').hidden = !isLast;
this.form.querySelector('[data-action="prev"]').disabled = this.current === 0;
}
validateCurrentStep() {
const inputs = this.steps[this.current].querySelectorAll('[required]');
return Array.from(inputs).every(input => {
if (!input.value.trim()) {
input.focus();
return false;
}
return true;
});
}
next() {
if (!this.validateCurrentStep()) return;
if (this.current < this.steps.length - 1) {
this.current++;
this.render();
}
}
prev() {
if (this.current > 0) {
this.current--;
this.render();
}
}
async submit(event) {
event.preventDefault();
const data = Object.fromEntries(new FormData(this.form));
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
this.form.innerHTML = '<p>Submitted successfully!</p>';
}
}
}
const wizard = new MultiStepForm('registrationForm');
Draft Saving with localStorage
For long forms, saving the draft automatically prevents losing work on accidental navigation:
const DRAFT_KEY = 'form-draft-registration';
function saveDraft(form) {
const data = Object.fromEntries(new FormData(form));
// Don't persist passwords
delete data.password;
delete data.confirmPassword;
localStorage.setItem(DRAFT_KEY, JSON.stringify(data));
}
function restoreDraft(form) {
const saved = localStorage.getItem(DRAFT_KEY);
if (!saved) return;
const data = JSON.parse(saved);
Object.entries(data).forEach(([name, value]) => {
const field = form.elements[name];
if (field) field.value = value;
});
}
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
}
// Auto-save every 2 seconds while the user is editing
const form = document.getElementById('registrationForm');
let draftTimer;
form.addEventListener('input', () => {
clearTimeout(draftTimer);
draftTimer = setTimeout(() => saveDraft(form), 2000);
});
// Restore on page load
window.addEventListener('load', () => restoreDraft(form));
// Clear after successful submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
await submitForm(form);
clearDraft();
});
Summary
Solid form handling requires attention at each step of the data flow:
- Call
event.preventDefault()to control the submit behavior - Use
FormDatato collect all field values uniformly, including file inputs - Validate on
blurfor per-field feedback, on submit to show all errors before sending - Always validate on the server — client validation is UX, not security
- Disable the submit button during requests to prevent double-submission
- Validate file size and type before uploading to avoid unnecessary round trips
Comments