Working with forms and validation
Forms are where a page stops showing things and starts receiving them. They are also where module 2's warning arrives in person: everything a form gives you is a string, and everything it gives you is a suggestion rather than a fact.
Handling a submit
<form id="order-form">
<input type="text" name="customer" required />
<input type="number" name="plates" value="3" />
<button type="submit">Place order</button>
</form>
const form = document.querySelector('#order-form');
form.addEventListener('submit', (event) => {
event.preventDefault();
console.log('submitted');
});
Two things that must be right:
The listener goes on the form, not the button. A form can be submitted by pressing Enter in a text field, and a button listener misses that entirely.
preventDefault() is not optional. Without it the browser submits the form
the old way and reloads the page — your log flashes past and vanishes. A handler
that "does nothing" with the page blinking is this.
Note that <button> inside a form defaults to type="submit". A button that
should not submit needs type="button" explicitly — a very common cause of a
page mysteriously reloading.
Reading the values
Two approaches. The direct one:
const customer = form.elements.customer.value;
const plates = form.elements.plates.value;
form.elements looks fields up by their name attribute, which is also what
a server would receive. And the better one for a whole form:
form.addEventListener('submit', (event) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(form));
console.log(data);
});
{ customer: 'Priya', plates: '3', pincode: '411014', slot: 'lunch', area: 'wagholi', note: '' }
FormData collects every named field; Object.fromEntries — module 4 — turns it
into a plain object.
Look at plates. It is '3', a string. Even from <input type="number">.
This is module 2's bug with a form attached:
console.log(data.plates + 1);
console.log(Number(data.plates) + 1);
31
4
Convert at the boundary, always.
The field types that behave differently
| Control | Read with | Notes |
|---|---|---|
| text, email, textarea | .value |
Always a string |
| number | .value or .valueAsNumber |
.value is a string |
| checkbox | .checked |
.value is 'on' and useless |
| radio group | form.elements.<name>.value |
The selected one |
| select | .value |
The selected option's value |
| file | .files |
A FileList |
Three of those are worth dwelling on.
A checkbox's .value is 'on' whether or not it is ticked. Use .checked,
which is a real boolean.
An unchecked checkbox is absent from FormData entirely:
console.log(new FormData(form).get('urgent'));
null
So Object.fromEntries gives you an object with no urgent key at all when
it is unticked, and urgent: 'on' when it is. A !data.urgent check happens to
work; a data.urgent === false check never does. Read checkboxes from
.checked, or normalise straight away.
A number input rejects non-numeric text rather than storing it:
plates.value = 'abc';
console.log(plates.value);
console.log(plates.valueAsNumber);
NaN
An empty string, not 'abc'. So a user typing letters gives you '', which
Number('') turns into 0 — module 2's trap, arriving by a route you would not
have predicted. Check for empty before converting.
Validation the browser does for you
HTML has validation built in, and it works without JavaScript:
<input type="text" name="pincode" required pattern="[1-9][0-9]{5}" />
| Attribute | Checks |
|---|---|
required |
Not empty |
pattern |
Matches a regular expression — module 6 |
min / max |
Number range |
minlength / maxlength |
Text length |
type="email" / type="url" |
Rough format |
Use these first. They are free, they work before your script loads, and screen readers understand them.
The JavaScript side lets you read the same results:
const pincode = form.elements.pincode;
pincode.value = '041101';
console.log(pincode.checkValidity());
console.log(pincode.validity.patternMismatch);
false
true
validity explains why it failed:
| Property | Means |
|---|---|
valueMissing |
required and empty |
patternMismatch |
Failed pattern |
rangeUnderflow / rangeOverflow |
Outside min / max |
tooShort / tooLong |
Length |
typeMismatch |
Wrong for type |
customError |
You set one |
And form.checkValidity() answers for the whole form at once.
Your own messages
The browser's default message is generic. setCustomValidity replaces it:
pincode.setCustomValidity('A pincode is six digits and cannot start with zero.');
console.log(pincode.checkValidity());
false
An element with a custom validity message is invalid until you clear it, and you clear it by setting an empty string:
pincode.setCustomValidity('');
Forgetting that line leaves a field permanently un-submittable no matter what the user types, which is a genuinely maddening bug to be on the receiving end of. Clear it at the start of every check.
Showing errors well
Turning off the browser's bubbles and rendering your own is normal:
<form id="order-form" novalidate>
<input type="text" name="pincode" required pattern="[1-9][0-9]{5}" />
<p class="error" id="pincode-error"></p>
</form>
form.addEventListener('submit', (event) => {
event.preventDefault();
const pincode = form.elements.pincode;
const error = document.querySelector('#pincode-error');
pincode.setCustomValidity('');
error.textContent = '';
if (!pincode.checkValidity()) {
error.textContent = pincode.validity.valueMissing
? 'Please enter a pincode.'
: 'Six digits, not starting with zero.';
pincode.focus();
return;
}
console.log('valid');
});
novalidate keeps the browser's own popups out of the way while leaving the
validation rules in place for your code to read. Note the focus() — sending
the user to the field they need to fix is the difference between a form that is
usable and one that is not.
Three things that make error messages actually work:
- Say what to do, not what is wrong. "Six digits, not starting with zero" beats "Invalid format".
- Show them next to the field, not in an alert at the top.
- Clear them when the user starts fixing it — an
inputlistener that empties the message.
And then do it again on the server
Module 1 said never trust the client, and a form is where that matters most.
Everything above runs in the user's browser, where they can open devtools and
change it. required can be deleted from the element. pattern can be removed.
Your handler can be replaced. A request can be sent with no browser at all.
Client-side validation is a courtesy to honest users — instant feedback, no round trip. Server-side validation is the actual control. Every backend course here re-validates everything, and that is not duplication; they are doing different jobs.
Other useful bits
form.reset() restores every field to its HTML attribute value, not to
empty — so a field written as value="3" goes back to 3, not blank. That is
the attribute-versus-property split from the selecting lesson, showing up
usefully for once.
For live feedback as somebody types, listen for input rather than change, as
in the events lesson.
Check your work
The submit listener belongs on the form, or Enter-to-submit is missed. A
<button> in a form is a submit button unless given type="button".
Without preventDefault() the page reloads and your handler's work is lost.
Object.fromEntries(new FormData(form)) gives a plain object — and every
value is a string, including from type="number". data.plates + 1 is '31'.
A checkbox's .value is 'on' regardless. Use .checked. An unchecked
checkbox is absent from FormData, so its key is missing rather than false.
A number input given non-numeric text yields '', not the text — and
Number('') is 0.
checkValidity() is a boolean; validity says why. A bad pincode gives
patternMismatch: true.
setCustomValidity('message') makes a field invalid until cleared with
setCustomValidity(''). Forgetting to clear it leaves the field permanently
invalid.
novalidate suppresses the browser's popups but keeps the rules readable by
your JavaScript.
form.reset() restores attribute values, so value="3" returns to 3.
Client validation is a courtesy; server validation is the control.
Practice
- Build the order form and log a message on submit. Forget
preventDefaultfirst, watch the reload, then add it. - Add a
<button>with notypenext to the submit button and watch it submit the form too. Fix it withtype="button". - Read the values with
form.elements, then withObject.fromEntries(new FormData(form)). Print both. - Prove the string problem. Add 1 to the plate count straight from the form
and get
'31'. Then convert and get4. - Add a checkbox. Print its
.valueand.checkedboth ticked and unticked. Then print theFormDataobject each way and find the missing key. - Type letters into a number input and print
.valueand.valueAsNumber. Then work out whatNumber(value)gives and why that is dangerous. - Add
requiredand apatternfor a pincode. Submit it empty, then wrong, then right, printingvalidityeach time. - Set a custom validity message and forget to clear it. Confirm the field stays invalid no matter what is typed. Then clear it properly.
- Add
novalidateand render your own error message under the field, withfocus()on the bad one. - Clear the error as the user types, using an
inputlistener. - Harder. Build a full tiffin order form: customer name, plate count,
pincode, a delivery slot as radio buttons, and an urgent checkbox. On
submit, validate everything, show per-field messages, focus the first bad
field, and on success render the order into a list and reset the form. Keep
the collected order as a proper object —
platesa number,urgenta real boolean — not as the strings the form handed you. Then, in a comment, name one thing an attacker could do to this form that your validation would not stop.
Next: localStorage — making the order survive a reload, and why it is not a
database.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship