Section 4 · JavaScript in the Browser
Forms and Validation
17 min3 min readCode playground
Lesson at a glance
Handle a form’s submit event without reloading the page, read and convert its values, validate them with HTML attributes and your own function, and show clear, accessible error messages.
- 1The form and the submit event
- 2Reading the values
- 3Validation in a pure function
- 4Putting it together
- 5Clearing errors as the user types
- 6HTML validation: the first layer
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Pocket needs a way to add expenses that is not a “sample” button: a form with a label, an amount and a category. Forms look simple and are where many real bugs live, because they sit on the border between people, who type anything, and your code, which expects clean data.
The form and the submit event
Start with proper HTML. Every input has a <label>, and the button is a submit button inside the <form>:
<form id="expense-form" novalidate>
<label for="label">What</label>
<input id="label" name="label" required>
<label for="amount">Amount ($)</label>
<input id="amount" name="amount" type="number" step="0.01" min="0.01" required>
<label for="category">Category</label>
<select id="category" name="category">
<option value="food">Food</option>
<option value="travel">Travel</option>
<option value="fun">Fun</option>
</select>
<p id="form-error" class="error" role="alert"></p>
<button type="submit">Add expense</button>
</form>A form can be submitted by clicking the button, by pressing Enter in a field, or by assistive technology. All of these fire one event on the form: submit. Listen to that, not to the button’s click:
const form = document.querySelector("#expense-form");
form.addEventListener("submit", (event) => {
event.preventDefault();
// read, validate, add, render
});The browser’s default reaction to a submit is to send the data to a server and load a new page. For an app that runs in the page, that reload wipes out everything. event.preventDefault() cancels the default and keeps you on the page.
Reading the values
You can read each field’s value, or collect them all at once with FormData, which uses each field’s name attribute:
const data = new FormData(form);
const rawLabel = data.get("label"); // " Coffee with Sam "
const rawAmount = data.get("amount"); // "4.50": a string!
const category = data.get("category"); // "food"Every value is a string, even from a number input. This is the “typed input” problem from section 1, now for real. Clean the values up immediately: trim() the label, and convert the amount once, into cents:
const label = rawLabel.trim();
const amount = Math.round(Number(rawAmount) * 100);Validation in a pure function
Put the rules in one function that takes values and returns what is wrong with them. It does not touch the page, so it is easy to read and easy to test:
function validateExpense({ label, amount }) {
const errors = {};
if (label.trim() === "") {
errors.label = "Enter what you spent money on.";
}
if (!Number.isFinite(amount) || amount <= 0) {
errors.amount = "Enter an amount greater than zero.";
}
return errors;
}
console.log(validateExpense({ label: " ", amount: Number("") }));
console.log(validateExpense({ label: "Coffee", amount: 4.5 }));An empty object means “valid”, which you test with Object.keys(errors).length === 0. Notice Number("") is 0, the empty-input trap from section 1, so “nothing typed” correctly fails the amount <= 0 rule, and Number.isFinite rejects NaN and Infinity in one check.
Putting it together
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
const values = {
label: data.get("label").trim(),
amount: Math.round(Number(data.get("amount")) * 100),
category: data.get("category"),
};
const errors = validateExpense(values);
const errorEl = document.querySelector("#form-error");
if (Object.keys(errors).length > 0) {
errorEl.textContent = Object.values(errors).join(" ");
const firstBad = form.elements[Object.keys(errors)[0]];
firstBad.setAttribute("aria-invalid", "true");
firstBad.focus();
return;
}
errorEl.textContent = "";
expenses = [...expenses, { id: nextId(), ...values }];
renderExpenses();
form.reset();
form.elements.label.focus();
});On failure, the message goes in an element with role="alert", which screen readers announce as soon as its text changes; the first invalid field is marked with aria-invalid and receives focus, so the user can fix it straight away. On success, the data changes, the list re-renders, form.reset() empties the fields, and focus returns to the first field, ready for the next expense. A real app would also clear aria-invalid once a field is fixed. form.elements.label looks up a field by its name.
Clearing errors as the user types
Showing an error is half the job; the other half is taking it away at the right moment. If the message stays on screen while the user is already typing the fix, the form feels like it is scolding them. Listen for input events on the form (they bubble, so one listener covers every field) and clear the error state of whichever field changed:
form.addEventListener("input", (event) => {
event.target.removeAttribute("aria-invalid");
document.querySelector("#form-error").textContent = "";
});Resist the opposite urge, validating every keystroke and showing “Enter an amount” while someone is halfway through typing it. A good default: validate on submit, clear on input.
HTML validation: the first layer
The required, type="number", min and step attributes let the browser check basic rules and show its own messages. The novalidate attribute on the form above turns those messages off so yours are shown consistently, while the attributes still document intent and give phones the right keyboard. You can also ask the browser directly with form.checkValidity().
Your form now adds real expenses. Reload the page, though, and they are gone, because they only ever lived in a variable. Next lesson fixes that with JSON and localStorage.
Key takeaways
- Listen for
submiton the form, notclickon the button, so every way of submitting (button, Enter key, assistive technology) runs your code, and callevent.preventDefault()to stop the page reloading. - Every value read from a form is a string; trim text and convert numbers before using them.
- Keep validation in a pure function that takes values and returns errors, so the rules are easy to read and test.
- HTML attributes such as
requiredandminare a useful first layer, but your JavaScript must still validate, and the server must too. - Show errors next to the field, mark it with
aria-invalid, and move focus so keyboard and screen-reader users notice.