Skip to main content

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:

  1. Clicking a submit button (<button type="submit"> or <input type="submit">)
  2. 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.

note

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)
});
});
warning

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

Featureform.submit()form.requestSubmit()
Fires submit eventNoYes
Runs HTML validationNoYes
Triggers submit handlerNoYes
event.submitterN/AThe passed button or first submit button
Browser supportAll browsersAll modern browsers
tip

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
});
warning

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.

AttributeDescriptionExample
requiredField 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" />
minlengthMinimum character count<input minlength="3" />
maxlengthMaximum character count<input maxlength="100" />
minMinimum numeric value<input type="number" min="0" />
maxMaximum numeric value<input type="number" max="100" />
stepValid numeric intervals<input type="number" step="5" />
patternRegex 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.

warning

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;
}
note

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 submit event fires on the <form> element when the user clicks a submit button or presses Enter. Always listen on the form, not on the button. Use event.submitter to identify which button triggered submission.
  • event.preventDefault() stops the browser from navigating away, letting you handle data with JavaScript and fetch. Disable the submit button during async operations to prevent double submission.
  • form.submit() submits programmatically but skips the submit event and validation. Use form.requestSubmit() instead, which fires the event and runs validation like a real user submission.
  • The action, method, and enctype attributes control where data is sent, how it is transmitted, and how it is encoded. Individual buttons can override these with formaction, formmethod, and formenctype.
  • Validate before submission using manual checks in the submit handler, real-time validation with input and blur events, or the built-in Constraint Validation API.
  • The HTML5 Constraint Validation API provides checkValidity(), reportValidity(), the validity object with detailed error states, setCustomValidity() for custom rules, and CSS pseudo-classes like :valid, :invalid, and :user-invalid for styling.