Forms

Beginner40 min

Read form input with FormData, check it live with the input and submit events and the constraint validation API, and learn why a server must never trust a browser's checks.

What you will learn
  • Read all the fields of a form with FormData
  • React to the input and submit events
  • Use the constraint validation API with setCustomValidity
  • Explain why client-side validation never replaces server-side checks
On this page

This lesson belongs to the optional browser module. Serve your folder as in JavaScript in a web page and open the page in a browser.

Forms are how a page asks the visitor for something. The browser already does much of the work: it draws the fields, lets the visitor type, and can refuse to submit a form that is clearly incomplete. Your JavaScript adds your own rules, clearer messages and a response that does not reload the page.

Reading the fields

new FormData(form) collects the current values of the form’s fields. It is filled using the name of each control as the key, so a field without a name is left out. A FormData object can be looped over with for...of, and each item is a [name, value] pair. That makes Object.fromEntries a quick way to turn it into a plain object.

const data = Object.fromEntries(new FormData(form));
// { username: "ada", email: "ada@example.com", ... }

If a name occurs more than once, such as a group of checkboxes, Object.fromEntries keeps only the last value. formData.getAll(name) returns all of them as an array.

Events for forms

Two events do most of the work:

  • input fires on a field when its value is changed by the visitor, for example with each key press. Use it for live feedback.
  • submit fires on the form when the visitor submits it, for example with a submit button. Its default action is to send the form and load a new page. For a page that handles the data itself, call event.preventDefault() first.

A form that fails validation does not fire submit at all. The browser fires an invalid event instead and shows the problem.

Constraint validation

HTML attributes such as required and type="email" set rules for a field, and the browser checks them. This is called constraint validation, and it has a JavaScript side:

  • form.checkValidity() returns true if every field is valid.
  • form.reportValidity() does the same check and also shows the problems to the visitor.
  • field.setCustomValidity(message) adds your own error message to a field. As long as the message is not an empty string, the field counts as invalid and the form will not submit. You must set the message back to "" when the problem is gone.

That last call connects your rules to the browser’s machinery. You decide what is wrong, the browser blocks the submit and shows your message.

Validation rules as a pure function

The rules do not need a page. This function takes the values and returns an object with one message for each field that has a problem.

signup_rules.js
export function validateSignup({
username = "",
email = "",
password = "",
confirm = "",
}) {
const errors = {};
const name = username.trim();
if (name.length < 3) {
errors.username = "Use at least 3 characters.";
} else if (/\s/.test(name)) {
errors.username = "No spaces, please.";
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = "Enter an address like name@example.com.";
}

The last two rules and the return value:

signup_rules.js (continued)
if (password.length < 8) {
errors.password = "Use at least 8 characters.";
}
if (confirm !== password) {
errors.confirm = "The passwords do not match.";
}
return errors;
}

Two of the checks use regular expressions, the patterns written between slashes. /\s/.test(name) is true when name contains any whitespace character. The longer pattern, /^[^\s@]+@[^\s@]+\.[^\s@]+$/, describes “some text, an @, some text, a dot, some text”, with no spaces and no second @. Regular expressions have their own lessons in the intermediate level, so treat these two as ready-made tests for now.

The default values (username = "" and so on) let the function accept an object with missing fields. Check it with three inputs:

signup_rules_demo.mjs
import { validateSignup } from "./signup_rules.js";
console.log(
validateSignup({
username: "ada",
email: "ada@example.com",
password: "analytical",
confirm: "analytical",
}),
);
console.log(
validateSignup({
username: "a b",
email: "ada@",
password: "short",
confirm: "other",
}),
);
console.log(validateSignup({}));

Run node signup_rules_demo.mjs:

{}
{
username: 'No spaces, please.',
email: 'Enter an address like name@example.com.',
password: 'Use at least 8 characters.',
confirm: 'The passwords do not match.'
}
{
username: 'Use at least 3 characters.',
email: 'Enter an address like name@example.com.',
password: 'Use at least 8 characters.'
}

The first form is valid, so the result is an empty object. The second breaks four rules at once. The third, an empty object, breaks three, and has no confirm error because two empty strings are equal. Rules like these are easy to test and to reuse, including on a server written in JavaScript.

The sign-up page

The page has four fields and one message paragraph under each. Each <p class="error"> names its field in data-error-for.

signup.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sign up</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 1rem;
max-width: 24rem;
}
label { display: block; margin-top: 0.75rem; }
input { display: block; width: 100%; box-sizing: border-box; }
.error { color: #b00020; min-height: 1.25em; margin: 0.25rem 0 0; }
</style>

The script tag and the first two fields come next.

signup.html (continued)
<script type="module" src="signup.js"></script>
</head>
<body>
<h1>Sign up</h1>
<form id="signup">
<label>
Username
<input name="username" type="text" autocomplete="username" required />
</label>
<p class="error" data-error-for="username" aria-live="polite"></p>
<label>
Email
<input name="email" type="email" autocomplete="email" required />
</label>
<p class="error" data-error-for="email" aria-live="polite"></p>

The password field:

signup.html (continued)
<label>
Password
<input
name="password"
type="password"
autocomplete="new-password"
required
/>
</label>
<p class="error" data-error-for="password" aria-live="polite"></p>

The repeated password, the submit button and the result paragraph:

signup.html (continued)
<label>
Repeat the password
<input
name="confirm"
type="password"
autocomplete="new-password"
required
/>
</label>
<p class="error" data-error-for="confirm" aria-live="polite"></p>
<button type="submit">Create account</button>
</form>
<p id="result"></p>
</body>
</html>

The script ties the rules to the form.

signup.js
import { validateSignup } from "./signup_rules.js";
const form = document.querySelector("#signup");
const result = document.querySelector("#result");
const touched = new Set();
function check() {
const errors = validateSignup(Object.fromEntries(new FormData(form)));
for (const field of form.querySelectorAll("input")) {
const message = errors[field.name] ?? "";
field.setCustomValidity(message);
const output = form.querySelector(`[data-error-for="${field.name}"]`);
output.textContent = touched.has(field.name) ? message : "";
}
return errors;
}

Each field gets its own input listener.

signup.js (continued)
for (const field of form.querySelectorAll("input")) {
field.addEventListener("input", () => {
touched.add(field.name);
check();
});
}

The submit listener ties it together, and the last line runs check once at load.

signup.js (continued)
form.addEventListener("submit", (event) => {
event.preventDefault();
if (Object.keys(check()).length > 0) return;
const { username, email } = Object.fromEntries(new FormData(form));
result.textContent = `Welcome, ${username}! We would email ${email}.`;
form.reset();
touched.clear();
check();
});
check();
  • check builds a data object from the form, runs the rules, and for each input calls setCustomValidity with the error message, or an empty string. It also shows the message in the paragraph under the field, but only for fields the visitor has already touched. Nobody wants to be told off before they have typed anything.
  • Each field gets its own input listener. It records the field’s name in the touched set, then calls check.
  • The submit listener cancels the real submit. If there are errors it stops. Otherwise it reads the two values it needs, shows a welcome message, resets the form and checks again. The code does not put the password anywhere on the page.
  • The last line runs check once at load, so the custom rules are in place before anyone types. Every field has required, so the browser already blocks an empty form. What the early call adds is that your own messages are set from the start, for example the one for a confirm field that does not match.

Open signup.html. Type ad in the username box: “Use at least 3 characters.” appears under it. Fill the other fields, and give a repeated password that differs: the match message shows. Make them equal and press the button: you see the welcome message, and the form clears.

The text of the welcome message goes in with textContent. The visitor controls username, so this matters, as the DOM lesson explained.

Never trust the browser

All this checking is for the visitor’s convenience. Client-side validation is too easy to bypass, so a server must always validate what it receives too. Anyone can open the developer tools, remove a required attribute, change a script or send a request without using your page at all. Your server must treat every value it receives as untrusted and check it again. The reasons, and what else to check, are in the fundamentals lesson on clean code and security basics.

Because the rules here live in a plain module, a Node.js server can import the same validateSignup and run it on the received data. The browser check is a courtesy, and the server check is the real one.

Common pitfalls

  • A field without a name. FormData skips it, and data.email is undefined.
  • Forgetting preventDefault() in a submit handler. The page reloads and your result vanishes.
  • Never clearing a custom message. The field stays invalid forever. Always call setCustomValidity("") when the field is fine.
  • Showing errors too early. Wait until the visitor has touched the field or tried to submit.
  • Checking only in the browser. The server must check again.
  • Putting secrets in the page. Do not show or log passwords.
  • Using innerHTML for a message with the visitor’s text. Use textContent.

Exercises

ExerciseAn age rule

Write a pure function validateAge(text) that returns an error message, or "" when the age is fine. An empty value, a value that is not a whole number and an age outside 13 to 120 should each give a different message. Check it in Node with several inputs. Expected result: validateAge("") is an “enter your age” message, validateAge("abc") and validateAge("12.5") are the same “whole numbers” message, validateAge("12") and validateAge("121") are the range message, and validateAge("13") and validateAge(" 40 ") are "".

Show solution

Number("") is 0, so test for an empty value first. Number.isInteger rejects "abc" (which becomes NaN) and "12.5".

age_rule.js
export function validateAge(text) {
const trimmed = text.trim();
if (trimmed === "") return "Enter your age.";
const age = Number(trimmed);
if (!Number.isInteger(age)) return "Use whole numbers only.";
if (age < 13 || age > 120) return "Age must be between 13 and 120.";
return "";
}
age_rule_demo.mjs
import { validateAge } from "./age_rule.js";
for (const text of ["", "abc", "12.5", "12", "13", " 40 ", "121"]) {
console.log(JSON.stringify(text), "->", JSON.stringify(validateAge(text)));
}

Run node age_rule_demo.mjs:

"" -> "Enter your age."
"abc" -> "Use whole numbers only."
"12.5" -> "Use whole numbers only."
"12" -> "Age must be between 13 and 120."
"13" -> ""
" 40 " -> ""
"121" -> "Age must be between 13 and 120."

In a page you would call ageInput.setCustomValidity(validateAge(ageInput.value)) in an input listener, as check does for the other fields.

ExerciseCheck the request on the server

A friend says the server does not need to check the sign-up data, because the page already did. Show why that is wrong. Write server_check.mjs, which imports validateSignup from signup_rules.js and defines handleSignup(body). It returns "accepted" when there are no errors, and otherwise "rejected: " followed by the names of the bad fields, separated by commas. Call it with a valid body, and with a body that skipped the page and sent a short username and a short password.

Expected output:

accepted
rejected: username, password
Show solution

The server only sees the request. It cannot tell whether your page checked anything, and a visitor can disable the script, edit the page in the developer tools or send a request without loading the page at all. So the server runs the same rules again. Because the rules live in a plain module, the server can import them.

server_check.mjs
import { validateSignup } from "./signup_rules.js";
function handleSignup(body) {
const fields = Object.keys(validateSignup(body));
if (fields.length === 0) return "accepted";
return `rejected: ${fields.join(", ")}`;
}
const good = {
username: "ada",
email: "ada@example.com",
password: "analytical",
confirm: "analytical",
};
const sneaky = { ...good, username: "x", password: "short", confirm: "short" };
console.log(handleSignup(good));
console.log(handleSignup(sneaky));
Output
accepted
rejected: username, password

The page checks make the form pleasant to use. The server checks keep the system safe.

Summary

  • new FormData(form) collects the values of the named fields. Object.fromEntries turns it into an object.
  • The input event gives live feedback, and the submit event handles the whole form. Call preventDefault() to stop the page from reloading.
  • setCustomValidity(message) makes a field invalid with your message. Reset it to "" to make the field valid again.
  • Put rules in a pure function so you can test them in Node and reuse them elsewhere.
  • Client-side checks improve the experience but never protect anything. A server must validate again.
  • Next, you will combine DOM, events and storage in a small todo app.