How to Handle Form Submission in JavaScript
Form submission is one of the most fundamental interactions on the web. Every login page, checkout flow, contact form, and search bar relies on forms to collect and send user data. While HTML forms can submit data to a server on their own, JavaScript gives you full control over the process: validating data before it leaves the browser, preventing the default page reload, sending data asynchronously with fetch, and providing real-time feedback to the user.
This guide covers the submit event and how it fires, preventing default submission for custom handling, the difference between user-triggered and programmatic submission, the HTML attributes that control where and how form data is sent, and the powerful HTML5 Constraint Validation API that lets you validate forms with minimal JavaScript.
The submit Event
The submit event fires on a <form> element when the user attempts to submit the form. There are two primary ways a user can trigger form submission:
- Clicking a submit button (
<button type="submit">or<input type="submit">) - Pressing Enter while focused on an input field inside the form
<form id="login-form">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Log In</button>
</form>
<script>
const form = document.getElementById('login-form');
form.addEventListener('submit', (event) => {
console.log('Form submitted!');
});
</script>
The submit Event Fires on the Form, Not the Button
A common misconception is to listen for click on the submit button. While that works in some cases, it misses Enter key submissions and does not capture the form submission lifecycle correctly:
// ❌ Misses Enter key submission, wrong target
submitButton.addEventListener('click', () => {
console.log('Button clicked');
});
// ✅ Catches all submission methods
form.addEventListener('submit', (event) => {
console.log('Form submitted');
});
The submit event always fires on the <form> element itself, regardless of whether submission was triggered by a button click or an Enter keypress.
Enter Key Submission Behavior
When a form contains a text input and a submit button, pressing Enter while the text input is focused automatically submits the form. This is built-in browser behavior and does not require any JavaScript.
<form id="search-form">
<input type="text" name="query" placeholder="Search..." />
<button type="submit">Search</button>
</form>
<script>
document.getElementById('search-form').addEventListener('submit', (event) => {
event.preventDefault();
console.log('Search submitted via Enter or button click');
});
</script>
There is a subtle rule: if a form has only one text input and no submit button, pressing Enter still submits the form. If a form has multiple text inputs but no submit button, pressing Enter does not submit the form in most browsers.
If you need a form that submits on Enter without a visible button, you can include a hidden submit button: <button type="submit" style="display: none;"></button>. This ensures consistent Enter key behavior across browsers.
The Submit Button That Triggered Submission
When a form has multiple submit buttons, you can determine which one was clicked using the submitter property of the SubmitEvent:
<form id="action-form">
<input type="text" name="item" placeholder="Item name" />
<button type="submit" name="action" value="save">Save</button>
<button type="submit" name="action" value="delete">Delete</button>
</form>
<script>
document.getElementById('action-form').addEventListener('submit', (event) => {
event.preventDefault();
const submitter = event.submitter;
console.log(`Action: ${submitter.value}`); // "save" or "delete"
console.log(`Button text: ${submitter.textContent}`); // "Save" or "Delete"
});
</script>
The event.submitter property returns the button or input element that triggered the submission. If the form was submitted by pressing Enter (not clicking a button), event.submitter is the first submit button in the form, or null if there is no submit button.
Preventing Default Submission for Custom Handling
By default, submitting a form causes the browser to navigate to the URL specified in the action attribute, sending the form data as an HTTP request. This reloads the page (or loads a new one). In modern web applications, you almost always want to prevent this default behavior and handle submission with JavaScript instead.
event.preventDefault()
form.addEventListener('submit', (event) => {
event.preventDefault(); // Stop the page from reloading
// Now handle the form data with JavaScript
const formData = new FormData(form);
console.log('Username:', formData.get('username'));
console.log('Password:', formData.get('password'));
});
Without event.preventDefault(), the browser navigates away from the page and your JavaScript after that line never executes meaningfully.
Sending Form Data with Fetch
The most common pattern is preventing default submission and sending the data asynchronously:
<form id="contact-form">
<input type="text" name="name" placeholder="Your name" required />
<input type="email" name="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" required></textarea>
<button type="submit">Send Message</button>
<div id="status"></div>
</form>
<script>
const form = document.getElementById('contact-form');
const status = document.getElementById('status');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submitButton = form.querySelector('button[type="submit"]');
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
status.textContent = '';
try {
const formData = new FormData(form);
const response = await fetch('/api/contact', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const result = await response.json();
status.textContent = 'Message sent successfully!';
status.style.color = 'green';
form.reset(); // Clear the form
} catch (error) {
status.textContent = `Failed to send: ${error.message}`;
status.style.color = 'red';
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Send Message';
}
});
</script>
Sending as JSON Instead of FormData
Many APIs expect JSON rather than form-encoded data. Convert FormData to a plain object first:
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// data is now { name: "Alice", email: "alice@example.com", message: "Hello" }
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
});
Object.fromEntries(formData.entries()) only captures the last value for each field name. If your form has multiple fields with the same name (like checkboxes), use formData.getAll(name) to get all values:
const data = {};
for (const [key, value] of formData.entries()) {
if (data[key]) {
// Convert to array for duplicate keys
data[key] = [].concat(data[key], value);
} else {
data[key] = value;
}
}
Preventing Double Submission
Users sometimes click the submit button multiple times, especially on slow connections. This can cause duplicate orders, messages, or other unintended side effects:
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submitButton = event.submitter || form.querySelector('[type="submit"]');
// Prevent double submission
if (submitButton.disabled) return;
submitButton.disabled = true;
try {
await sendFormData(new FormData(form));
} finally {
submitButton.disabled = false;
}
});
A more robust approach uses a flag to track the submission state:
let isSubmitting = false;
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (isSubmitting) return;
isSubmitting = true;
const submitButton = form.querySelector('[type="submit"]');
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
try {
// Process form...
await submitForm();
} catch (error) {
showError(error.message);
} finally {
isSubmitting = false;
submitButton.disabled = false;
submitButton.textContent = 'Submit';
}
});
form.submit(): No Event Triggered
JavaScript provides a form.submit() method that submits the form programmatically. However, this method has a critical difference from user-initiated submission: it does not trigger the submit event.
const form = document.getElementById('my-form');
// This listener will NOT fire when form.submit() is called
form.addEventListener('submit', (event) => {
console.log('This will not run with form.submit()');
});
// Programmatic submission (bypasses the submit event)
form.submit(); // Form submits, page navigates, but no event fires
This means any validation or custom logic in your submit event handler is skipped entirely.
When to Use form.submit()
The form.submit() method is rarely the right choice. It exists primarily for cases where you need to trigger a traditional full-page form submission from JavaScript without going through event handlers:
// Rare use case: redirect to a payment gateway with POST data
function redirectToPayment(paymentUrl, token) {
const form = document.createElement('form');
form.method = 'POST';
form.action = paymentUrl;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'token';
input.value = token;
form.appendChild(input);
document.body.appendChild(form);
form.submit(); // Navigates to the payment page
}
form.requestSubmit(): The Better Alternative
The requestSubmit() method was introduced to solve the problem with form.submit(). It triggers submission as if the user clicked a submit button, which means the submit event fires and validation runs:
const form = document.getElementById('my-form');
form.addEventListener('submit', (event) => {
console.log('This WILL run with requestSubmit()');
event.preventDefault();
});
// Triggers the submit event and runs validation
form.requestSubmit();
You can also pass a specific submit button to requestSubmit():
const deleteButton = form.querySelector('button[value="delete"]');
form.requestSubmit(deleteButton);
// event.submitter will be the delete button
Comparison: submit() vs. requestSubmit()
| Feature | form.submit() | form.requestSubmit() |
|---|---|---|
Fires submit event | No | Yes |
| Runs HTML validation | No | Yes |
Triggers submit handler | No | Yes |
event.submitter | N/A | The passed button or first submit button |
| Browser support | All browsers | All modern browsers |
Always prefer requestSubmit() over submit() for programmatic form submission. It behaves like a real user submission, running validation and firing events as expected.
method, action, and enctype Attributes
The HTML <form> element has several attributes that control how and where the form data is sent. Understanding these is important even when you handle submission with JavaScript, because they define the default behavior and can be read programmatically.
action: Where the Data Goes
The action attribute specifies the URL to which the form data is sent:
<!-- Data sent to /api/login -->
<form action="/api/login" method="POST">
...
</form>
<!-- Data sent to the current page URL (default when action is omitted) -->
<form method="POST">
...
</form>
<!-- Data sent to an absolute URL -->
<form action="https://example.com/submit" method="POST">
...
</form>
When using JavaScript to handle submission, you can read the action programmatically:
form.addEventListener('submit', async (event) => {
event.preventDefault();
const url = form.action; // The resolved action URL
const response = await fetch(url, {
method: form.method,
body: new FormData(form)
});
});
Individual submit buttons can override the form's action using the formaction attribute:
<form action="/api/save" method="POST">
<input type="text" name="data" />
<button type="submit">Save</button>
<button type="submit" formaction="/api/save-draft">Save as Draft</button>
</form>
method: How the Data Is Sent
The method attribute specifies the HTTP method:
<!-- GET: data appended to URL as query string -->
<form action="/search" method="GET">
<input type="text" name="q" />
<button type="submit">Search</button>
</form>
<!-- Submits to: /search?q=javascript -->
<!-- POST: data sent in the request body -->
<form action="/api/login" method="POST">
<input type="text" name="username" />
<input type="password" name="password" />
<button type="submit">Log In</button>
</form>
GET is appropriate for idempotent requests like search queries. The data is visible in the URL and can be bookmarked. POST is appropriate for data that modifies server state (creating accounts, placing orders, uploading files). The data is in the request body, not the URL.
Like action, individual buttons can override the method with formmethod:
<form action="/api/items" method="POST">
<input type="text" name="item" />
<button type="submit">Create (POST)</button>
<button type="submit" formmethod="GET" formaction="/api/search">Search (GET)</button>
</form>
enctype: How the Data Is Encoded
The enctype attribute determines how form data is encoded in the request body. It only applies to POST requests.
application/x-www-form-urlencoded (default):
<form action="/api/login" method="POST">
<!-- enctype defaults to application/x-www-form-urlencoded -->
<input type="text" name="username" value="alice" />
<input type="password" name="password" value="secret" />
</form>
<!-- Body: username=alice&password=secret -->
Data is encoded as key-value pairs separated by &, with special characters percent-encoded. This is the default and works for most text-based forms.
multipart/form-data (required for file uploads):
<form action="/api/upload" method="POST" enctype="multipart/form-data">
<input type="text" name="title" />
<input type="file" name="document" />
<button type="submit">Upload</button>
</form>
Each field is sent as a separate part with its own content type. This is the only encoding that supports file uploads. When you use FormData with fetch, the browser automatically sets multipart/form-data with the correct boundary:
// fetch with FormData automatically uses multipart/form-data
const response = await fetch('/api/upload', {
method: 'POST',
body: new FormData(form) // Content-Type set automatically
});
When sending FormData with fetch, do not manually set the Content-Type header. The browser needs to set it automatically to include the multipart boundary string:
// ❌ Wrong: manually setting Content-Type breaks multipart boundary
const response = await fetch('/api/upload', {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data' // Missing boundary!
},
body: new FormData(form)
});
// ✅ Correct: let the browser set Content-Type
const response = await fetch('/api/upload', {
method: 'POST',
body: new FormData(form) // Browser adds correct Content-Type with boundary
});
text/plain (rarely used):
<form action="/api/data" method="POST" enctype="text/plain">
<input type="text" name="message" value="Hello World" />
</form>
<!-- Body: message=Hello World (no encoding of special characters) -->
This encoding is almost never used in practice. It sends data as plain text without URL encoding.
Reading Form Attributes in JavaScript
form.addEventListener('submit', (event) => {
event.preventDefault();
console.log('Action:', form.action); // Full resolved URL
console.log('Method:', form.method); // "get" or "post" (lowercase)
console.log('Enctype:', form.enctype); // Encoding type
// Use form attributes to build the fetch request
fetch(form.action, {
method: form.method.toUpperCase(),
body: form.method.toLowerCase() === 'post' ? new FormData(form) : undefined
});
});
Validation Before Submission
Validating form data before sending it to the server improves user experience by providing immediate feedback and reduces unnecessary server load. JavaScript offers two approaches: manual validation with custom logic and the built-in HTML5 Constraint Validation API.
Manual Validation
The simplest approach is checking field values in your submit handler:
form.addEventListener('submit', (event) => {
event.preventDefault();
const errors = [];
const name = form.elements.name.value.trim();
const email = form.elements.email.value.trim();
const password = form.elements.password.value;
if (name.length === 0) {
errors.push('Name is required');
} else if (name.length < 2) {
errors.push('Name must be at least 2 characters');
}
if (!email) {
errors.push('Email is required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push('Please enter a valid email address');
}
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
if (errors.length > 0) {
displayErrors(errors);
return; // Stop submission
}
// All valid (send the data)
submitFormData(new FormData(form));
});
function displayErrors(errors) {
const errorList = document.getElementById('error-list');
errorList.innerHTML = errors.map(e => `<li>${e}</li>`).join('');
errorList.style.display = 'block';
}
Real-Time Validation with input and blur Events
Waiting until submission to show errors is not ideal. Combining validation with input and blur events provides immediate feedback:
<form id="signup-form">
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" />
<span class="error-message" id="email-error"></span>
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" />
<span class="error-message" id="password-error"></span>
</div>
<button type="submit">Sign Up</button>
</form>
<script>
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
// Validate on blur (when user leaves the field)
emailInput.addEventListener('blur', () => {
validateEmail();
});
// Clear error while typing (give user a chance to fix)
emailInput.addEventListener('input', () => {
if (emailInput.value.trim() !== '') {
document.getElementById('email-error').textContent = '';
}
});
passwordInput.addEventListener('blur', () => {
validatePassword();
});
passwordInput.addEventListener('input', () => {
if (passwordInput.value.length >= 8) {
document.getElementById('password-error').textContent = '';
}
});
function validateEmail() {
const value = emailInput.value.trim();
const errorEl = document.getElementById('email-error');
if (!value) {
errorEl.textContent = 'Email is required';
return false;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errorEl.textContent = 'Please enter a valid email';
return false;
}
errorEl.textContent = '';
return true;
}
function validatePassword() {
const value = passwordInput.value;
const errorEl = document.getElementById('password-error');
if (value.length < 8) {
errorEl.textContent = 'Password must be at least 8 characters';
return false;
}
errorEl.textContent = '';
return true;
}
// Final check on submit
document.getElementById('signup-form').addEventListener('submit', (event) => {
event.preventDefault();
const isEmailValid = validateEmail();
const isPasswordValid = validatePassword();
if (isEmailValid && isPasswordValid) {
console.log('Form is valid, submitting...');
}
});
</script>
HTML5 Constraint Validation API
HTML5 introduced built-in form validation that works without JavaScript. By adding validation attributes to form elements, the browser automatically checks values on submission and shows error messages. The Constraint Validation API lets you interact with this system from JavaScript for custom behavior and styling.
Validation Attributes
HTML provides several attributes for declarative validation:
<form id="registration">
<!-- Required field -->
<input type="text" name="username" required />
<!-- Email format validation -->
<input type="email" name="email" required />
<!-- Minimum and maximum length -->
<input type="text" name="nickname" minlength="3" maxlength="20" />
<!-- Numeric range -->
<input type="number" name="age" min="18" max="120" required />
<!-- Pattern (regex) validation -->
<input type="text" name="zipcode" pattern="\d{5}" title="Five digit zip code" required />
<!-- URL format -->
<input type="url" name="website" />
<!-- Step validation for numbers -->
<input type="number" name="quantity" min="0" max="100" step="5" />
<button type="submit">Register</button>
</form>
When the user clicks submit, the browser checks all fields against their validation attributes. If any field is invalid, the browser shows a native error tooltip and prevents submission. No JavaScript needed.
| Attribute | Description | Example |
|---|---|---|
required | Field must not be empty | <input required /> |
type="email" | Must be a valid email format | <input type="email" /> |
type="url" | Must be a valid URL | <input type="url" /> |
minlength | Minimum character count | <input minlength="3" /> |
maxlength | Maximum character count | <input maxlength="100" /> |
min | Minimum numeric value | <input type="number" min="0" /> |
max | Maximum numeric value | <input type="number" max="100" /> |
step | Valid numeric intervals | <input type="number" step="5" /> |
pattern | Regex the value must match | <input pattern="\d{3}-\d{4}" /> |
The novalidate Attribute
If you want to handle validation entirely in JavaScript, disable browser validation with novalidate:
<!-- Browser will NOT validate on submit -->
<form id="my-form" novalidate>
<input type="email" name="email" required />
<button type="submit">Submit</button>
</form>
With novalidate, the validation attributes are still present and the Constraint Validation API still works, but the browser will not automatically block submission or show tooltips. This is useful when you want to use the API for checking validity while providing custom error display.
Individual buttons can also bypass validation with formnovalidate:
<form>
<input type="email" name="email" required />
<button type="submit">Submit (validates)</button>
<button type="submit" formnovalidate>Save Draft (skips validation)</button>
</form>
checkValidity() and reportValidity()
The Constraint Validation API provides methods to check validity programmatically:
checkValidity() returns true or false without showing any visual feedback:
const emailInput = document.querySelector('input[name="email"]');
// Check a single field
if (emailInput.checkValidity()) {
console.log('Email is valid');
} else {
console.log('Email is invalid');
}
// Check the entire form
const form = document.getElementById('my-form');
if (form.checkValidity()) {
console.log('All fields are valid');
} else {
console.log('Form has invalid fields');
}
reportValidity() does the same check but also shows the browser's native error tooltips:
// Check and show error tooltips
if (!form.reportValidity()) {
console.log('Form is invalid - browser is showing error messages');
return;
}
The validity Property
Every form element has a validity property that returns a ValidityState object. This object has boolean properties for each type of validation failure:
const input = document.querySelector('input[name="age"]');
console.log(input.validity.valid); // true if all checks pass
console.log(input.validity.valueMissing); // true if required and empty
console.log(input.validity.typeMismatch); // true if wrong type (email, url)
console.log(input.validity.patternMismatch);// true if pattern doesn't match
console.log(input.validity.tooShort); // true if shorter than minlength
console.log(input.validity.tooLong); // true if longer than maxlength
console.log(input.validity.rangeUnderflow); // true if below min
console.log(input.validity.rangeOverflow); // true if above max
console.log(input.validity.stepMismatch); // true if doesn't match step
console.log(input.validity.badInput); // true if browser can't parse
console.log(input.validity.customError); // true if setCustomValidity was called
Using validity for detailed error messages:
function getValidationMessage(input) {
const v = input.validity;
if (v.valueMissing) return 'This field is required';
if (v.typeMismatch) return `Please enter a valid ${input.type}`;
if (v.patternMismatch) return input.title || 'Value does not match the required format';
if (v.tooShort) return `Must be at least ${input.minLength} characters (currently ${input.value.length})`;
if (v.tooLong) return `Must be no more than ${input.maxLength} characters`;
if (v.rangeUnderflow) return `Must be at least ${input.min}`;
if (v.rangeOverflow) return `Must be no more than ${input.max}`;
if (v.stepMismatch) return `Must be a multiple of ${input.step}`;
if (v.badInput) return 'Please enter a valid value';
if (v.customError) return input.validationMessage;
return '';
}
validationMessage Property
Each form element has a validationMessage property that returns the browser's default error message string:
const emailInput = document.querySelector('input[type="email"]');
emailInput.value = 'not-an-email';
console.log(emailInput.validationMessage);
// Something like: "Please include an '@' in the email address."
// (exact message varies by browser and locale)
setCustomValidity(): Custom Validation Rules
The setCustomValidity() method lets you define custom validation rules that integrate with the browser's validation system. Pass a non-empty string to mark the field as invalid, or an empty string to mark it as valid:
const passwordInput = document.getElementById('password');
const confirmInput = document.getElementById('confirm-password');
confirmInput.addEventListener('input', () => {
if (confirmInput.value !== passwordInput.value) {
confirmInput.setCustomValidity('Passwords do not match');
} else {
confirmInput.setCustomValidity(''); // Clear the error (field is valid)
}
});
Now when the form is submitted, the browser treats the confirm field as invalid if the passwords do not match, showing the custom message in the native tooltip.
After calling setCustomValidity() with an error message, you must call it again with an empty string when the condition is resolved. Otherwise, the field remains permanently invalid even after the user corrects the input.
// ❌ Field stays invalid forever after first mismatch
confirmInput.addEventListener('input', () => {
if (confirmInput.value !== passwordInput.value) {
confirmInput.setCustomValidity('Passwords do not match');
}
// Missing: else { confirmInput.setCustomValidity(''); }
});
The invalid Event
The invalid event fires on a form element when it fails validation (during checkValidity(), reportValidity(), or form submission). This event does not bubble.
const inputs = form.querySelectorAll('input');
inputs.forEach(input => {
input.addEventListener('invalid', (event) => {
// Prevent the browser's default tooltip
event.preventDefault();
// Show custom error styling
input.classList.add('error');
// Show custom error message
const errorSpan = input.parentElement.querySelector('.error-message');
if (errorSpan) {
errorSpan.textContent = getValidationMessage(input);
}
});
});
CSS Pseudo-Classes for Validation State
CSS provides pseudo-classes that react to validation state, enabling styling without JavaScript:
/* Valid fields */
input:valid {
border-color: #2ecc71;
}
/* Invalid fields */
input:invalid {
border-color: #e74c3c;
}
/* Only show invalid styling after interaction */
input:invalid:not(:placeholder-shown) {
border-color: #e74c3c;
}
/* Required fields */
input:required {
border-left: 3px solid #3498db;
}
/* Optional fields */
input:optional {
border-left: 3px solid #95a5a6;
}
/* Fields in range */
input[type="number"]:in-range {
background: #eafaf1;
}
/* Fields out of range */
input[type="number"]:out-of-range {
background: #fdedec;
}
The :invalid pseudo-class applies immediately on page load, even before the user interacts with the form. This can show error styling on empty required fields before the user has typed anything. Use :invalid:not(:placeholder-shown) or :user-invalid (newer browsers) to apply styling only after user interaction.
The :user-invalid pseudo-class (supported in modern browsers) solves this elegantly:
/* Only shows invalid styling after the user has interacted */
input:user-invalid {
border-color: #e74c3c;
background: #fdedec;
}
Complete Example: Custom Validation with Constraint API
Here is a complete form that uses HTML validation attributes, the Constraint Validation API for checking, and custom JavaScript for error display:
<style>
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 4px; font-weight: bold; }
.form-group input {
width: 100%;
padding: 8px 12px;
border: 2px solid #ccc;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.2s;
box-sizing: border-box;
}
.form-group input.touched:invalid { border-color: #e74c3c; }
.form-group input.touched:valid { border-color: #2ecc71; }
.error-text {
color: #e74c3c;
font-size: 13px;
margin-top: 4px;
min-height: 20px;
}
.submit-btn {
padding: 10px 24px;
background: #3498db;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.submit-btn:disabled { background: #95a5a6; cursor: not-allowed; }
</style>
<form id="registration-form" novalidate>
<div class="form-group">
<label for="reg-name">Full Name</label>
<input type="text" id="reg-name" name="name" required minlength="2" maxlength="50" />
<div class="error-text" data-for="reg-name"></div>
</div>
<div class="form-group">
<label for="reg-email">Email</label>
<input type="email" id="reg-email" name="email" required />
<div class="error-text" data-for="reg-email"></div>
</div>
<div class="form-group">
<label for="reg-password">Password (min 8 characters)</label>
<input type="password" id="reg-password" name="password" required minlength="8" />
<div class="error-text" data-for="reg-password"></div>
</div>
<div class="form-group">
<label for="reg-confirm">Confirm Password</label>
<input type="password" id="reg-confirm" name="confirmPassword" required />
<div class="error-text" data-for="reg-confirm"></div>
</div>
<button type="submit" class="submit-btn">Create Account</button>
</form>
<script>
const form = document.getElementById('registration-form');
const password = document.getElementById('reg-password');
const confirm = document.getElementById('reg-confirm');
// Custom validation: password match
function checkPasswordMatch() {
if (confirm.value && confirm.value !== password.value) {
confirm.setCustomValidity('Passwords do not match');
} else {
confirm.setCustomValidity('');
}
}
password.addEventListener('input', checkPasswordMatch);
confirm.addEventListener('input', checkPasswordMatch);
// Show errors for a specific field
function showFieldError(input) {
const errorDiv = document.querySelector(`[data-for="${input.id}"]`);
if (!errorDiv) return;
if (input.validity.valid) {
errorDiv.textContent = '';
} else {
errorDiv.textContent = getErrorMessage(input);
}
}
function getErrorMessage(input) {
if (input.validity.valueMissing) return `${input.labels[0]?.textContent || 'This field'} is required`;
if (input.validity.typeMismatch) return `Please enter a valid ${input.type}`;
if (input.validity.tooShort) return `Must be at least ${input.minLength} characters`;
if (input.validity.tooLong) return `Must be at most ${input.maxLength} characters`;
if (input.validity.patternMismatch) return input.title || 'Invalid format';
if (input.validity.customError) return input.validationMessage;
return 'Invalid value';
}
// Mark fields as "touched" on blur to enable CSS styling
form.querySelectorAll('input').forEach(input => {
input.addEventListener('blur', () => {
input.classList.add('touched');
showFieldError(input);
});
input.addEventListener('input', () => {
if (input.classList.contains('touched')) {
showFieldError(input);
}
});
});
// Handle submission
form.addEventListener('submit', async (event) => {
event.preventDefault();
// Mark all fields as touched
form.querySelectorAll('input').forEach(input => {
input.classList.add('touched');
showFieldError(input);
});
// Check overall form validity
if (!form.checkValidity()) {
// Focus the first invalid field
const firstInvalid = form.querySelector('input:invalid');
if (firstInvalid) firstInvalid.focus();
return;
}
// Form is valid: submit
const submitBtn = form.querySelector('.submit-btn');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating Account...';
try {
const data = Object.fromEntries(new FormData(form).entries());
delete data.confirmPassword; // Don't send confirmation field
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error('Registration failed');
console.log('Account created successfully!');
form.reset();
form.querySelectorAll('.touched').forEach(el => el.classList.remove('touched'));
} catch (error) {
console.error(error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create Account';
}
});
</script>
This example combines HTML validation attributes, setCustomValidity() for the password match check, the validity property for detailed error messages, CSS class toggling for styling, and async fetch for submission. The novalidate attribute on the form disables the browser's native tooltips, letting the custom error display handle everything.
Summary
Form submission in JavaScript gives you full control over data collection, validation, and transmission:
- The
submitevent fires on the<form>element when the user clicks a submit button or presses Enter. Always listen on the form, not on the button. Useevent.submitterto identify which button triggered submission. event.preventDefault()stops the browser from navigating away, letting you handle data with JavaScript andfetch. Disable the submit button during async operations to prevent double submission.form.submit()submits programmatically but skips thesubmitevent and validation. Useform.requestSubmit()instead, which fires the event and runs validation like a real user submission.- The
action,method, andenctypeattributes control where data is sent, how it is transmitted, and how it is encoded. Individual buttons can override these withformaction,formmethod, andformenctype. - Validate before submission using manual checks in the
submithandler, real-time validation withinputandblurevents, or the built-in Constraint Validation API. - The HTML5 Constraint Validation API provides
checkValidity(),reportValidity(), thevalidityobject with detailed error states,setCustomValidity()for custom rules, and CSS pseudo-classes like:valid,:invalid, and:user-invalidfor styling.