JavaScript Forms: Reading Values, Handling Submit and Simple Validation
We read form values with value, checked and selectedIndex, catch the submit event, write checks for empty fields, email and length, show errors on the page and get to know HTML's built-in validation.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
The page has a number box with 30 in it. The box's type is explicitly number:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Predict</title>
</head>
<body>
<input id="age" type="number" value="30">
<script>
const age = document.getElementById("age").value;
console.log(age + 1);
</script>
</body>
</html>
What gets printed to the console? 31, or something else? Make a guess before you keep reading. The answer is in the section where we talk about how to read the values of form fields.
The Parts of a Form and How to Reach Them
A form is like a paper application between you and the user. The boxes arrive empty, the user fills them in, and when you take it back you check whether anything is missing or wrong. That's exactly JavaScript's job with forms: read the values, catch the submission and, if needed, say "fix this part."
Let's start with a small sign-up form we'll use throughout the post. Save it, open it, fill in the fields and press Sign up:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sign-up Form</title>
</head>
<body>
<form id="signup">
<p>
<label for="name">Name</label>
<input id="name" type="text">
</p>
<p>
<label for="age">Age</label>
<input id="age" type="number">
</p>
<p>
<label for="city">City</label>
<select id="city">
<option value="">Choose one</option>
<option value="ist">Istanbul</option>
<option value="ank">Ankara</option>
<option value="izm">Izmir</option>
</select>
</p>
<p>
<label><input type="radio" name="plan" value="free" checked> Free</label>
<label><input type="radio" name="plan" value="pro"> Pro</label>
</p>
<p>
<label><input id="newsletter" type="checkbox"> Subscribe to the newsletter</label>
</p>
<button>Sign up</button>
</form>
<script>
const form = document.getElementById("signup");
form.addEventListener("submit", (event) => {
event.preventDefault();
const name = document.getElementById("name").value.trim();
const ageText = document.getElementById("age").value;
const cityBox = document.getElementById("city");
const plan = document.querySelector('input[name="plan"]:checked').value;
const newsletter = document.getElementById("newsletter").checked;
console.log("Name:", name);
console.log("Age:", Number(ageText));
console.log("City code:", cityBox.value);
console.log("Selected index:", cityBox.selectedIndex);
console.log("Visible text:", cityBox.options[cityBox.selectedIndex].text);
console.log("Plan:", plan);
console.log("Newsletter:", newsletter);
});
</script>
</body>
</html>
Form fields are DOM elements too; you reach them with the getElementById and querySelector we saw earlier, just as before. The for attribute of a <label> links the label to the box with the same id. When the user clicks the text, the box gets focus, and screen readers can announce what the box is for. It looks like a small detail, but it noticeably improves how usable a form is.
There are more convenient ways to reach all the fields of a form at once, such as form.elements and FormData. We'll cover them in the Intermediate series. For now, selecting each field one by one is more instructive, because you see exactly where each value comes from.
value, checked and selectedIndex
The content of text boxes, number boxes and selects lives in the value property. That's also where the answer to the opening question hides: you see 301 in the console, not 31. value always returns a string. The box being of type number doesn't change that; the browser just makes it harder for the user to type letters. Since what you hold is the string "30", + 1 doesn't add, it does the string concatenation we saw earlier. If you need to do math with it, convert it with Number() first.
For checkboxes and radio buttons, the real question is "is it checked?", so you look at the checked property, which returns true or false. In a radio group, the :checked selector finds which of the buttons sharing the same name is selected:
const selected = document.querySelector('input[name="plan"]:checked');
console.log(selected ? selected.value : "Nothing selected");
If none is selected, querySelector returns null, so checking first is a good habit. In our form we made one option checked from the start, so we don't run into this.
On a select, value gives you the value attribute of the selected <option>: the screen shows "Istanbul" but you read "ist". selectedIndex tells you the position of the selected option, counting from zero. If no option is selected, it returns -1. If you want the text the user sees, get it with options[selectedIndex].text.
The submit Event: Catching the Submission
When you read the values matters as much as how you read them. In the example above we did all the reading inside the submit listener. The reason is simple: we want to know what's in the boxes at the moment the user, having filled in the form, says "send."
Two rules from the events post apply here too. First, add the listener to the form, not to the button. The user can also submit the form by pressing Enter; submit runs in both cases, while the button's click only runs on a click. Second, don't forget event.preventDefault(). The browser's default behavior is to send the form to an address and reload the page. We stop that because we'll handle the data with JavaScript. We'll see how to send a form to a server without reloading the page when we cover the Fetch API.
Simple Validation Rules
Validation is the answer to the question "can I accept this data?" The three checks you'll run into most often are: is the field empty, does the email look like an email, is it long enough. Writing each as a small function that returns an error message if there's a problem and an empty string if there isn't makes things much easier:
function checkName(name) {
if (name === "") return "Name can't be empty.";
if (name.length < 2) return "Name must be at least 2 characters.";
return "";
}
function checkEmail(email) {
if (email === "") return "Email can't be empty.";
if (!email.includes("@") || !email.includes(".")) {
return "Enter a valid email address.";
}
return "";
}
function checkPassword(password) {
if (password.length < 8) return "Password must be at least 8 characters.";
return "";
}
console.log(checkName(" ".trim())); // "Name can't be empty."
console.log(checkEmail("ada@example.com")); // ""
console.log(checkPassword("1234")); // "Password must be at least 8 characters."
Paste this into the console and try it with your own values. Notice we use trim() before the empty check; otherwise someone who only presses the space bar would slip through with a "filled" field. The email check is deliberately rough: it only looks for an @ and a dot. We'll write a stricter check with regular expressions, and that topic is coming up very soon.
Showing Errors on the Page
Validation is half checking and half telling the user clearly what went wrong. Writing the error to the console is enough for you, but users don't see the console. Writing the message right next to the field is the clearest way. This file brings everything together:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Validation</title>
<style>
.error { color: firebrick; font-size: 14px; }
.invalid { border: 2px solid firebrick; }
</style>
</head>
<body>
<form id="signup" novalidate>
<p>
<label for="name">Name</label>
<input id="name">
<span class="error" id="name-error"></span>
</p>
<p>
<label for="email">Email</label>
<input id="email" type="email">
<span class="error" id="email-error"></span>
</p>
<p>
<label for="password">Password</label>
<input id="password" type="password">
<span class="error" id="password-error"></span>
</p>
<p>
<label><input id="terms" type="checkbox"> I accept the terms of use</label>
<span class="error" id="terms-error"></span>
</p>
<button>Sign up</button>
</form>
<p id="result"></p>
<script>
const form = document.getElementById("signup");
const result = document.getElementById("result");
function checkName(name) {
if (name === "") return "Name can't be empty.";
if (name.length < 2) return "Name must be at least 2 characters.";
return "";
}
function checkEmail(email) {
if (email === "") return "Email can't be empty.";
if (!email.includes("@") || !email.includes(".")) {
return "Enter a valid email address.";
}
return "";
}
function checkPassword(password) {
if (password.length < 8) return "Password must be at least 8 characters.";
return "";
}
function showError(fieldId, message) {
document.getElementById(fieldId + "-error").textContent = message;
document.getElementById(fieldId).classList.toggle("invalid", message !== "");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const nameError = checkName(document.getElementById("name").value.trim());
const emailError = checkEmail(document.getElementById("email").value.trim());
const passwordError = checkPassword(document.getElementById("password").value);
const termsError = document.getElementById("terms").checked
? ""
: "You need to accept the terms to continue.";
showError("name", nameError);
showError("email", emailError);
showError("password", passwordError);
showError("terms", termsError);
if (nameError || emailError || passwordError || termsError) {
result.textContent = "";
return;
}
result.textContent = "Signed up successfully, welcome!";
form.reset();
});
</script>
</body>
</html>
Next to every field there's an empty <span>. The showError function writes the message into that span with textContent and adds or removes the class that gives the box a red border. The second argument we pass to classList.toggle says directly whether the class should be there: true adds it, false removes it. That way, when the user fixes a mistake and submits again, the old message clears itself.
The line if (nameError || emailError || ...) relies on the truthy/falsy logic we saw earlier: an empty string is falsy, a message is truthy. If any field has a message, we leave early with return. If everything is clean, we write the success message and reset the fields with form.reset(). We'll see what the novalidate on the form tag does right now.
HTML's Built-in Validation
For some checks you don't even need a single line of JavaScript. HTML itself offers a set of validation attributes:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Built-in Validation</title>
</head>
<body>
<form id="signup">
<p><input id="name" placeholder="Name" required minlength="2"></p>
<p><input id="email" type="email" placeholder="Email" required></p>
<p><input id="password" type="password" placeholder="Password" required minlength="8"></p>
<p><input id="age" type="number" placeholder="Age" min="18" max="120"></p>
<button>Sign up</button>
</form>
<script>
document.getElementById("signup").addEventListener("submit", (event) => {
event.preventDefault();
console.log("submit ran, every field is valid.");
});
</script>
</body>
</html>
required makes a field mandatory. minlength and maxlength limit the length of the text, min and max limit the range of a number. type="email" asks for the value to be in email format. Try submitting the form empty: the browser shows its own warning bubble next to the first invalid field and nothing is printed to the console. That's because the browser runs these checks before the submit event; if the form is invalid, the event never fires.
These attributes are a free first line of defense, and you should always use them. They have downsides too: the messages come out in the browser's language and style, and you can't change how they look much. That's why we added novalidate to the form tag in the previous example. That attribute turns off the browser's own warnings and leaves the checking entirely to our JavaScript. There's also the pattern attribute, which requires a field to match a certain pattern and works with regular expressions. We'll see how to read HTML validation from JavaScript and combine it with your own messages in the Intermediate series.
Common Pitfalls
Using a number as a string
As we saw in the opening question, value is always a string. There's one more subtlety: for an empty number box, Number("") gives 0, not NaN. So "the user didn't enter an age" and "the user typed 0" look the same. Do the empty check before converting to a number.
Reading the value at the wrong time
If you write const name = document.getElementById("name").value; outside the listener, at the top of the script, the value is read the moment the page opens. At that moment the box is empty, and the variable holds an empty string forever. Read the value when you need it, which means inside the listener.
Every button in a form submitting it
The default type of a <button> inside a form is submit. If you add a second button like "Clear" or "Show password" to the form, clicking it also submits the form. Write type="button" on buttons that shouldn't submit.
Forgetting preventDefault
Because the page reloads, the messages in the console vanish too, and you think "the code doesn't run at all." In that situation, the Preserve log option in the DevTools console settings (Persist Logs in Firefox) keeps the messages even when the page reloads.
Trusting validation in the browser
Validation in the browser is there to guide the user, not for security. Your code runs on the user's computer; anyone can open DevTools and switch your checks off. The server receiving the data must always run the same checks itself.
A Small Experiment
★☆☆ Add an input listener to every box in the validation example. When the user starts typing, that field's error message should disappear.
★★☆ Predict before running: in the sign-up form, if you leave the age box empty and press Sign up, what do you see next to "Age:" in the console? Then type 25 into the age box and try the expression document.getElementById("age").value + 5 in the console.
★★★ Add a "Repeat password" field to the validation example. If the two passwords don't match, show the message "Passwords don't match." Also add a "Show password" checkbox; when it's checked, set the password box's type to "text", and back to "password" when it's unchecked.
So far we've been working inside the page: elements, events, forms. In the next post we'll take a step back and look at the window that holds the page. We'll read the address bar, move through the browser history, find out the screen size and use timers to put code off until later.