Stack Patterns ■ Episode 8
The average React project installs a validation library before
a single input field exists in the markup. To verify what,
precisely? That an email contains an @. That a
required field has a value. That a name contains letters.
The browser has done this since 2014. Rather quietly, given how few seem to have noticed.
The Pattern
HTML5 introduced constraint validation as part of the W3C Recommendation of 28 October 2014. The mechanism is declarative: you describe the constraints in HTML attributes, and the browser enforces them on submit.
<form>
<input type="email" required>
<input type="text" required
minlength="2" pattern="[A-Za-z\s\-']+">
<input type="url">
<button type="submit">Send</button>
</form>
Required fields must have values. Email fields must contain emails. Pattern fields must match the regex. The browser checks on submit, blocks if invalid, tells the user what went wrong.
No onChange. No useState. No schema
object. No bundle size.
The UX Objection, Resolved
"Native validation shows errors on page load." Fair criticism.
It did. The :invalid pseudo-class matched the
moment the DOM rendered, painting every empty required field
red before the user had typed a single character. It was, by
any measure, rude.
Then, in 2023,
:user-invalid
arrived.
input:user-invalid {
outline: 2px solid #c00;
}
input:user-valid {
outline: 2px solid #0a0;
}
:user-invalid fires only after the user has
interacted with the field. Load the page: pristine. Tab
through empty fields: pristine. Type something, leave the
field: now it validates. The single biggest UX complaint
about native validation, resolved in pure CSS.
Custom Error Messages
"But I need custom error messages." One line:
input.setCustomValidity("Please use your work email.");
The
Constraint Validation API
ships with every browser. The element.validity
object exposes eleven boolean properties:
valueMissing, typeMismatch,
patternMismatch, tooShort,
tooLong, rangeOverflow,
rangeUnderflow, stepMismatch,
badInput, customError, and
valid. Each tells you precisely what failed.
All free. All native. All shipped in 2014.
For those who want a complete example with custom messages, the pattern is straightforward:
const email = document.querySelector('[type="email"]');
email.addEventListener('invalid', () => {
email.setCustomValidity(
email.validity.typeMismatch
? "That does not look like an email."
: "Email is required."
);
});
email.addEventListener('input', () => {
email.setCustomValidity("");
});
Two event listeners. One for the error, one to clear it when the user corrects the input. The browser handles the rest.
Cross-Field Validation
"But what about cross-field validation? What if the submit button should disable until the entire form is valid?"
form:has(:invalid) [type="submit"] {
opacity: 0.4;
pointer-events: none;
}
If any field inside the form is invalid, the submit button
dims. No JavaScript. CSS checks on every reflow. The
:has()
pseudo-class turns the parent into a state machine. The form
observes its own children and reacts accordingly.
When This Is Not Enough
"Does this username already exist?" Fair question. That
requires a server round-trip. No amount of client-side
validation can answer it. One fetch call:
const r = await fetch(`/api/check?user=${encodeURIComponent(name)}`);
One server handler (Rust):
async fn check(q: Query<Name>) -> Json<bool> {
Json(db.exists(&q.name).await)
}
Four lines. No schema library. No validation framework. Just a question and an answer.
That is perhaps 5% of forms. The other 95% need nothing the browser did not already provide.
"But server-side validation!" Quite right. Client validation is UX. Server validation is security. You need both. But the client side was already written for you. By the browser team. In 2014.
The Complete Form
For those who appreciate a working example, here is a registration form with full native validation, visual feedback, and cross-field awareness. Zero dependencies:
<form id="signup">
<label>Email
<input type="email" required
placeholder="you@company.com">
</label>
<label>Password
<input type="password" required
minlength="8" maxlength="128">
</label>
<label>Website
<input type="url"
placeholder="https://...">
</label>
<button type="submit">Sign Up</button>
</form>
/* Validation feedback (no JS) */
input:user-invalid { outline: 2px solid #c00; }
input:user-valid { outline: 2px solid #0a0; }
/* Disable submit until valid */
form:has(:invalid) [type="submit"] {
opacity: 0.4;
pointer-events: none;
}
Load the page: every field pristine, button dimmed. Fill in a valid email: green outline, button still dimmed (password empty). Fill in a password of eight characters: green outline, button activates. Entire interaction model in HTML and CSS. The JavaScript team can have the afternoon off.