Skip to main content

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) and method (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; :invalid CSS pseudo-class activates at the same time
  • Always re-validate on the server - browser checks are bypassed by bots and direct HTTP requests
  • Add novalidate to skip browser tooltip UI while still running checkValidity() in code

Quick example

html
<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

ElementWhat 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:

html
<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:

js
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 pass

On 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

css
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:

html
<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.

js
// 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.

html
<!-- 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.

html
<!-- 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.

js
// 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 native required on the input element
  • Stripe card forms use pattern and :invalid for real-time format feedback before the SDK takes over
  • Common production setup: novalidate on the form with express-validator mirroring the rules server-side
  • formnovalidate on 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

html
<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

html
<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

html
<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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?