HTML forms and built-in validation
HTML forms collect user input through controls like <input>, <select>, and <textarea> inside a <form> element. Built-in validation checks that data against rules like required or pattern before the browser sends the request.
Theory
TL;DR
- Form = a container with
action(where to send) andmethod(POST/GET); inputs are the fields users fill - Built-in validation runs without JavaScript via
required,type="email",min/max,pattern - Browser blocks submit and shows a tooltip if any field fails;
:invalidCSS pseudo-class activates at the same time - Always re-validate on the server - browser checks are bypassed by bots and direct HTTP requests
- Add
novalidateto skip browser tooltip UI while still runningcheckValidity()in code
Quick example
<form action="/submit" method="POST">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<!-- Empty submit: "Please fill out this field" -->
<!-- Bad format: "Please include an '@' in the email address" -->
<label for="age">Age (18+):</label>
<input type="number" id="age" name="age" min="18" required>
<!-- Age 17: "Value must be greater than or equal to 18" -->
<button type="submit">Send</button>
</form>Two attributes do all the work: type="email" tells the browser what format to expect, required blocks empty submission. No JavaScript needed.
Form elements
| Element | What it does |
|---|---|
<input> | Text, email, password, checkbox, radio, number, tel, range, date |
<textarea> | Multi-line text |
<select> | Dropdown |
<fieldset> | Groups related controls |
<label> | Ties text to a control (click label = focus input) |
<datalist> | Autocomplete suggestions for <input> |
Validation attributes
Each attribute maps to a specific check the browser runs before submit:
<input type="text" required> <!-- blocks empty -->
<input type="email"> <!-- checks for @ and domain -->
<input type="number" min="1" max="100"> <!-- numeric range -->
<input type="text" minlength="3" maxlength="50"> <!-- string length -->
<input type="text" pattern="[a-zA-Z0-9_-]+"
title="Letters, numbers, underscore, hyphen only"> <!-- regex match -->
<input type="tel" pattern="\d{4}-\d{4}-\d{4}-\d{4}"> <!-- card format -->The title attribute sets the tooltip text shown when pattern fails. Without it, browsers show a generic message.
How validation works internally
Browsers parse form attributes into a ValidityState object on each input. You can read it directly:
const input = document.querySelector('#email');
console.log(input.validity.valueMissing); // true if empty + required
console.log(input.validity.typeMismatch); // true if wrong format
console.log(input.validity.valid); // true only if all checks passOn submit, the browser calls checkValidity() on every field. If any returns false, it stops the request, focuses the first bad field, and shows a native tooltip. No server request happens. The :invalid CSS pseudo-class activates at the same time.
CSS for validation states
input:valid { border-color: green; }
input:invalid { border-color: red; }
/* Don't mark empty fields red on page load */
input:invalid:not(:focus):not(:placeholder-shown) {
border-color: red;
}That last selector solves a common UX problem. Without it, all required fields show red borders immediately on page load before the user touches anything.
novalidate and custom error UI
Add novalidate to the form when you want to handle errors yourself (toasts, inline messages) but still run checks in code:
<form id="payment" novalidate>
<input type="tel" name="card"
pattern="\d{4}-\d{4}-\d{4}-\d{4}" required>
<button type="submit" disabled>Pay</button>
</form>
<script>
const form = document.getElementById('payment');
const btn = form.querySelector('button');
form.addEventListener('input', () => {
btn.disabled = !form.checkValidity();
});
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.querySelectorAll(':invalid').forEach(field => {
// show your own error UI here
});
}
});
</script>novalidate skips the browser tooltip on submit. checkValidity() still does the real validation work in code.
Common mistakes
Trusting client-side checks alone
Browser validation is invisible to the server. A bot can POST directly with curl and skip every required attribute you wrote. Always re-check server-side.
// Express.js - mirror the HTML rules on the server
if (!req.body.email.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}required on hidden inputs
Hidden fields with required always fail validation because their value is "". This breaks multi-step wizards silently.
<!-- Wrong: always blocks submit -->
<input type="text" name="step" required style="display:none" value="">
<!-- Fix: hidden inputs don't use required -->
<input type="hidden" name="step" value="2">Forgetting the name attribute
Without name, the field is invisible to FormData and never reaches the server. I've caught this in code reviews more often than any other form bug.
<!-- Wrong: field submits nothing -->
<input id="username" required>
<!-- Fix -->
<input id="username" name="username" required>Calling setCustomValidity('') unconditionally
Clearing the custom message before checking wipes real errors too.
// Wrong: clears the error on every input event regardless
input.setCustomValidity('');
// Fix: clear only when the field is actually valid
input.setCustomValidity(input.validity.valid ? '' : 'Custom error message');Real-world usage
- React Hook Form:
register('email', { required: true })maps to nativerequiredon the input element - Stripe card forms use
patternand:invalidfor real-time format feedback before the SDK takes over - Common production setup:
novalidateon the form withexpress-validatormirroring the rules server-side formnovalidateon a "Save Draft" button lets users save incomplete forms without triggering validation
Follow-up questions
Q: What happens when form submission fails validation?
A: The browser cancels the request, applies :invalid styles, focuses the first bad field, and shows a native tooltip. You can also trigger this manually with form.reportValidity().
Q: What is the difference between required and minlength="1"?
A: required blocks empty values including whitespace-only strings. minlength="1" allows empty submission but blocks a short non-empty value.
Q: Can validation run on blur instead of only on submit?
A: Yes. Add input.addEventListener('blur', () => input.reportValidity()) to show errors the moment a user leaves a field.
Q: What does novalidate actually do?
A: It tells the browser to skip native tooltip UI on submit. It does not disable checkValidity() or :invalid styles. Senior answer: use it when you need your own error rendering like toasts or modals, while keeping programmatic checks active.
Q: How does built-in validation interact with FormData?
A: FormData is built only when the browser decides to send the form. If validation blocks submit, FormData is never created and no network request is made.
Examples
Basic: Registration form with built-in checks
<form action="/api/users" method="POST">
<label for="username">Username (3-20 chars):</label>
<input type="text" id="username" name="username"
required minlength="3" maxlength="20"
pattern="[a-zA-Z0-9_-]+"
title="Letters, numbers, underscore, hyphen only">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required>
<label for="password">Password (min 8 chars):</label>
<input type="password" id="password" name="password"
required minlength="8">
<button type="submit">Create Account</button>
</form>
<style>
input:valid { border: 2px solid green; }
input:invalid { border: 2px solid red; }
</style>Username "ab " fails the pattern (spaces are not in the character class) and shows "Letters, numbers, underscore, hyphen only". Valid input gets a green border and the form POSTs all three fields via FormData.
Intermediate: GitHub-style signup with real-time feedback
<form id="signup" action="/api/register" method="POST" novalidate>
<fieldset>
<legend>Create your account</legend>
<label for="user">Username:</label>
<input type="text" id="user" name="username"
required minlength="3" maxlength="39"
pattern="[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?">
<span class="error" id="user-error"></span>
<label for="mail">Email:</label>
<input type="email" id="mail" name="email" required>
<button type="submit">Sign up</button>
</fieldset>
</form>
<script>
const form = document.getElementById('signup');
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
form.querySelectorAll(':invalid').forEach(field => {
const errorEl = document.getElementById(field.id + '-error');
if (errorEl) errorEl.textContent = field.validationMessage;
});
}
});
form.querySelectorAll('input').forEach(input => {
input.addEventListener('input', () => {
const errorEl = document.getElementById(input.id + '-error');
if (errorEl && input.validity.valid) errorEl.textContent = '';
});
});
</script>novalidate removes the browser tooltip. field.validationMessage is a browser-generated string like "Please include an '@' in the email address" - you get the native error text without writing your own copy.
Advanced: Payment form with disabled submit until valid
<form id="checkout" novalidate>
<label for="card">Card number (XXXX-XXXX-XXXX-XXXX):</label>
<input type="tel" id="card" name="card"
pattern="\d{4}-\d{4}-\d{4}-\d{4}" required>
<label for="tip">Tip (10-30%):</label>
<input type="range" id="tip" name="tip" min="10" max="30" value="15">
<button type="submit" id="pay-btn" disabled>Pay</button>
</form>
<script>
const form = document.getElementById('checkout');
const btn = document.getElementById('pay-btn');
function syncButton() {
btn.disabled = !form.checkValidity();
}
form.addEventListener('input', syncButton);
form.addEventListener('invalid', (e) => {
e.preventDefault();
e.target.setCustomValidity('Card must be formatted as XXXX-XXXX-XXXX-XXXX');
syncButton();
}, true); // capture phase - fires on every invalid field, not just the form
</script>The invalid event listener uses true as the third argument to capture it in the capture phase, so it fires on every field. The button stays disabled until form.checkValidity() returns true. This pattern is what Stripe-style forms use before handing off to a payment SDK.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.