RizTech Academy logo
RizTech Academy
The DOMLesson 5 of 730 min

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 input listener 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

  1. Build the order form and log a message on submit. Forget preventDefault first, watch the reload, then add it.
  2. Add a <button> with no type next to the submit button and watch it submit the form too. Fix it with type="button".
  3. Read the values with form.elements, then with Object.fromEntries(new FormData(form)). Print both.
  4. Prove the string problem. Add 1 to the plate count straight from the form and get '31'. Then convert and get 4.
  5. Add a checkbox. Print its .value and .checked both ticked and unticked. Then print the FormData object each way and find the missing key.
  6. Type letters into a number input and print .value and .valueAsNumber. Then work out what Number(value) gives and why that is dangerous.
  7. Add required and a pattern for a pincode. Submit it empty, then wrong, then right, printing validity each time.
  8. Set a custom validity message and forget to clear it. Confirm the field stays invalid no matter what is typed. Then clear it properly.
  9. Add novalidate and render your own error message under the field, with focus() on the bad one.
  10. Clear the error as the user types, using an input listener.
  11. 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 — plates a number, urgent a 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