JavaScript Practice #4 Working with Forms
In #3 fetch and Async UI you saw the patterns for fetching data and reflecting it on screen. This time we go the other direction — receiving user input, validating it, and sending it to the server.
Start with HTML built-in validation #
Before writing validation by hand in JavaScript, leverage HTML5 built-in validation first.
<form id="signup">
<input name="email" type="email" required>
<input name="password" type="password" minlength="8" required>
<input name="age" type="number" min="14" max="120">
<input name="username" pattern="[a-z0-9_]{3,20}" required>
<button type="submit">Sign up</button>
</form>This alone — blocking empty fields, email format checks, length/range checks, regex patterns — all works. On submission, the browser validates automatically and even shows an error tooltip on the first invalid field.
It’s usually wasteful to reimplement what’s already built in. Keep the built-in as your foundation, and layer JavaScript on top as needed.
Leveraging built-in validation — the Constraint Validation API #
The tools to use built-in validation from JavaScript.
const input = document.querySelector('input[name="email"]');
input.validity.valid; // all checks passed?
input.validity.valueMissing; // required violation
input.validity.typeMismatch; // type=email format violation
input.validity.tooShort; // minlength violation
input.validity.patternMismatch; // pattern violation
// ... etc.
input.validationMessage; // the message the browser would show
At the form level:
form.checkValidity(); // is the whole form valid
form.reportValidity(); // validate and show tooltip if invalid
With this API you can leverage built-in validation while displaying your own UI messages.
Custom error messages #
If the default messages feel off — English-only, too terse, or poorly translated — you can set your own.
input.addEventListener('input', () => {
if (input.value && !/^[a-z]+$/.test(input.value)) {
input.setCustomValidity('Lowercase only');
} else {
input.setCustomValidity(''); // must be empty to be considered valid
}
});If setCustomValidity('a non-empty message') is set, the input is considered invalid. To clear the validation, set it back to an empty string.
Validation + display — the manual pattern #
If you don’t like the built-in tooltips, render the messages yourself. A common shape:
<form id="signup" novalidate>
<label>
Email
<input name="email" type="email" required>
<span class="error" data-for="email"></span>
</label>
<label>
Password
<input name="password" type="password" minlength="8" required>
<span class="error" data-for="password"></span>
</label>
<button type="submit">Sign up</button>
</form>The novalidate attribute is the key — it disables automatic browser validation. You handle everything.
function showError(name, message) {
form.querySelector(`[data-for="${name}"]`).textContent = message;
}
function clearErrors() {
form.querySelectorAll('.error').forEach((el) => (el.textContent = ''));
}
form.addEventListener('submit', (e) => {
e.preventDefault();
clearErrors();
let hasError = false;
for (const field of form.elements) {
if (!field.name) continue;
if (!field.checkValidity()) {
showError(field.name, field.validationMessage);
hasError = true;
}
}
if (hasError) return;
const formData = new FormData(form);
submit(Object.fromEntries(formData));
});JavaScript leverages built-in validation while your own markup handles the messages. This approach is the most common and robust.
FormData — collect form input at once
#
The tool you saw in the React series works the same way in vanilla.
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!form.checkValidity()) return form.reportValidity();
const formData = new FormData(form);
// Single field
formData.get('email'); // 'me@example.com'
// All key-value pairs
for (const [key, value] of formData) {
console.log(key, value);
}
// Convert to object
const data = Object.fromEntries(formData);
// { email: '...', password: '...', ... }
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
});Pass the form whole into new FormData(form) and it auto-collects every input. Only fields with a name attribute are included.
Sending FormData as-is — multipart #
You can also send FormData directly as the fetch body without converting to JSON. This is the natural choice when there’s a file upload.
form.addEventListener('submit', async (e) => {
e.preventDefault();
await fetch('/api/upload', {
method: 'POST',
body: new FormData(form),
// Don't set Content-Type yourself — the browser sets it with the boundary automatically
});
});The pattern you saw in Intermediate #6 fetch API.
Working with checkboxes / multi-select #
<input type="checkbox" name="tags" value="js">
<input type="checkbox" name="tags" value="ts">
<input type="checkbox" name="tags" value="react">
<select name="category" multiple>
<option value="news">News</option>
<option value="tip">Tip</option>
</select>const formData = new FormData(form);
formData.get('tags'); // first value only ('js')
formData.getAll('tags'); // ['js', 'ts'] — all selected
formData.getAll('category'); // every value of a multi-select
get returns the first value, getAll returns them all as an array. Always use getAll for checkbox groups to be safe.
Object.fromEntries(formData) keeps only the last value when a key appears multiple times, so for forms with multiple values you need to handle those keys directly.
const data = {};
for (const key of new Set(formData.keys())) {
const all = formData.getAll(key);
data[key] = all.length > 1 ? all : all[0];
}Common validation patterns #
1) Confirming two passwords match #
A case built-in validation can’t catch.
const password = form.elements.password;
const confirm = form.elements.passwordConfirm;
function syncPasswordMatch() {
if (password.value !== confirm.value) {
confirm.setCustomValidity('Passwords do not match');
} else {
confirm.setCustomValidity('');
}
}
password.addEventListener('input', syncPasswordMatch);
confirm.addEventListener('input', syncPasswordMatch);2) Async validation — checking for duplicate username #
A check that requires asking the server.
const username = form.elements.username;
const checkUsername = debounce(async (value) => {
if (!value) return;
const res = await fetch(`/api/check-username?u=${value}`);
const { available } = await res.json();
username.setCustomValidity(available ? '' : 'Username already taken');
}, 400);
username.addEventListener('input', (e) => checkUsername(e.target.value));The debounce pattern from #3 fetch and Async UI drops right in.
During submission — preventing duplicate submits #
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!form.checkValidity()) return form.reportValidity();
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
showSuccess('Sign-up complete');
form.reset();
} catch (err) {
showError(err.message);
} finally {
submitBtn.disabled = false;
}
});finally is the key — re-enables the button regardless of success or failure. Prevents accidents where the user submits the same form twice.
Form reset and initial values #
form.reset(); // reset every field to its initial value
input.defaultValue; // initial value (the one written in HTML)
input.value; // current value
reset reverts to the initial value written in HTML (like <input value="...">). Values populated dynamically from JavaScript aren’t initial values — to clear them explicitly, use input.value = ''.
change vs input — which event do you listen to?
#
input every keystroke / every change — real-time
change on focus loss / on select change — change completeFor real-time validation, use input. For heavier work “after input is done,” use change or a debounced input.
Validation on blur #
input.addEventListener('blur', () => {
if (!input.checkValidity()) {
input.classList.add('invalid');
showError(input.name, input.validationMessage);
} else {
input.classList.remove('invalid');
showError(input.name, '');
}
});This pattern skips validation while the user is still typing and only shows errors when focus leaves the field. Less annoying for users.
Accessibility — especially important for forms #
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error">
<span id="email-error" class="error" role="alert"></span>Three keys:
- Use
<label for="...">or place the input inside a<label> - Connect to the error region with
aria-describedby - Put
role="alert"oraria-live="polite"on the error region so screen readers read changes
Built-in messages get accessibility automatically, but errors you render yourself need the setup above.
Wrapping up #
What this post covered:
- Start with HTML built-in validation (
required,type,pattern,min/max) - Leverage it from JavaScript via the ValidityState API
- Custom messages with
setCustomValidity - The
novalidate+ manual handling pattern is the most common - Collect form input at once with
FormData - Send FormData as-is when files are involved (Content-Type set automatically)
- Use
getAllfor multiple values (checkbox groups, multi-select) - Augmenting patterns like password match and async username validation
disabledduring submit + restoration infinally- Accessibility —
label,aria-describedby,role="alert"
The next post (#5 Local Storage and Lightweight State Management) covers tools for storing data in the browser and patterns for managing UI state cleanly without a library.