You Don't Need a Form Library
The contact form is the oldest objection to static sites. But you can’t do a contact form without a server. That one was debunked years ago — a dozen services handle form submissions on your behalf, and the argument is spelled out in another post on this site.
But there’s a quieter assumption nested inside that objection that never gets examined: that client-side form validation requires a library. React Hook Form. Formik. Valibot. Zod applied on the frontend. The browser, in this framing, can collect input but can’t validate it — so the first thing you do is npm install react-hook-form and wire up a controller for every field.
That framing is about a decade out of date. HTML5 shipped native form validation in 2012. The browser enforces required fields, validates email formats, checks character counts, and matches against regex patterns — without a line of JavaScript. It exposes a full set of CSS pseudo-classes for styling valid and invalid states. In late 2023, the last meaningful gap closed: :user-invalid shipped across all evergreen browsers, fixing the page-load styling problem that had been the main reason to reach for a library.
We’re still installing form libraries out of habit. The platform has been waiting for us to notice.
What HTML5 Forms Actually Give You
Start with a contact form, let the browser validate it, and notice what you don’t have to write:
<form method="post" action="/api/contact">
<div class="field">
<label for="name">Name</label>
<input id="name" name="name" type="text"
required minlength="2" autocomplete="name">
</div>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email"
required autocomplete="email">
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message"
required minlength="10" maxlength="2000"></textarea>
</div>
<button type="submit">Send</button>
</form>
Hit submit with an empty form. The browser stops submission, focuses the first invalid field, and renders a native error popover. No JavaScript. No library. Every input declares its own constraints, and the browser enforces them automatically:
required— the field must have a non-empty value. Works oninput,textarea, andselect.type="email"— format validation, not just presence."hello"is invalid."hello@"is invalid."hello@example.com"passes. The browser applies a deliberately pragmatic check — intentionally simpler than RFC 5322, which the HTML spec calls a “willful violation” — that catches the malformed addresses that actually matter.minlength/maxlength— character count constraints, applied before submission.pattern— a JavaScript regex applied to the input value.pattern="[0-9]{5}"on a ZIP code field. No validation function needed.type="url",type="number",type="tel"— type-specific format checking.type="number"validates that the value is a number;type="url"checks for a valid URL scheme.min/max— lower and upper bounds fortype="number"andtype="date".step— granularity for numeric and date inputs.
These constraints are exposed through the Constraint Validation API: every input has a .validity object with boolean properties — valueMissing, typeMismatch, tooShort, tooLong, patternMismatch, rangeUnderflow, rangeOverflow, and the summary .valid. The browser reads these internally and blocks submit when any field is invalid. You only need to read them yourself when you want custom behavior beyond the native popover.
What you don’t get out of the box is control over the error styling. That’s what the pseudo-classes are for — and that’s where the story got complicated until recently.
:user-invalid: The Fix That Closed the Gap
The :invalid pseudo-class has existed for years. The problem: it fires on page load, before the user has touched anything. Style :invalid inputs with a red border and every required field on the page is visually red from the moment it renders. That’s not an error state — it’s an accusation against users who haven’t done anything wrong yet.
The workaround was manual state management. You’d track which fields had been “touched” with JavaScript, add CSS classes to mark them, and only show error styles for fields in the touched set. Libraries like React Hook Form and Formik abstract this into an API (field.touched), but the underlying mechanism is JavaScript tracking something the browser could have tracked natively.
In late 2023, the browser caught up. :user-invalid is now supported in all evergreen browsers (Firefox since 88 in 2021, Safari since 16.5 in 2023, Chrome since 119 in late 2023). It matches the same inputs as :invalid — but only after the user has interacted with the field. The empty required field you haven’t touched yet shows neutral styling. The field you tabbed through without filling in shows the error state. This is the right behavior, and it requires nothing but CSS:
/* ❌ Don't do this — shows errors on page load before any input */
input:invalid {
border-color: #dc2626;
}
/* ✓ Only fires after user interaction — bare selector so it covers
<textarea> and <select>, not just <input> */
:user-invalid {
border-color: #dc2626;
outline-color: #dc2626;
}
/* Reveal a sibling error span only when the field is user-invalid */
.field-error {
display: none;
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
}
:user-invalid ~ .field-error {
display: block;
}
The full set of useful constraint pseudo-classes, for reference:
| Pseudo-class | Fires when |
|---|---|
:required | required attribute is present |
:optional | required attribute is absent |
:valid | field value passes all constraints |
:invalid | field value fails any constraint (including on page load) |
:user-valid | :valid, but only after user interaction |
:user-invalid | :invalid, but only after user interaction |
:placeholder-shown | the field has a placeholder attribute and it’s currently shown |
The :user-invalid and :user-valid pair are the recent additions — Chrome, the last to ship them, landed in late 2023. They do everything :invalid and :valid do, but scoped to fields the user has actually touched. That’s the behavior form libraries have been emulating with JavaScript for years. It’s now a CSS selector.
Custom Error Messages Without a Library
The browser’s native error messages are functional but generic. Chrome’s text for an invalid email is: “Please include an ’@’ in the email address. ‘name’ is missing an ’@’.” That’s technically accurate and consistently wrong for every contact form that wants a cleaner voice.
setCustomValidity() replaces the browser’s message with your own:
const emailInput = document.querySelector('#email')
function validateEmail() {
if (emailInput.validity.valueMissing) {
emailInput.setCustomValidity('Email is required.')
} else if (emailInput.validity.typeMismatch) {
emailInput.setCustomValidity("That doesn't look like a valid email address.")
} else {
emailInput.setCustomValidity('') // ← clear the custom message
}
}
// 'input' re-runs as the user fixes the field so a stale message can't linger
// and block submit; 'blur' also catches the leave-it-empty case.
emailInput.addEventListener('input', validateEmail)
emailInput.addEventListener('blur', validateEmail)
The critical detail: calling setCustomValidity('') clears the custom message and lets native validation resume. If you set a message and never clear it, the field stays permanently invalid regardless of what the user types. The pattern is always: check .validity.* for the relevant constraints, set a message if something’s wrong, clear it if everything’s fine.
One listener per field. No library. The browser still handles blocking-on-submit behavior — you’re just overriding the displayed text.
The .validity properties you’ll use most often:
field.validity.valueMissing // required and empty
field.validity.typeMismatch // wrong type (email, url, number)
field.validity.tooShort // below minlength
field.validity.tooLong // above maxlength
field.validity.patternMismatch // doesn't match pattern=""
field.validity.rangeUnderflow // below min=
field.validity.rangeOverflow // above max=
field.validity.valid // all constraints pass
The novalidate Pattern (When You Want Full Control)
Sometimes you want to own the entire validation UI: custom-styled error divs below each field, accessible aria-describedby connections between the error text and its input, a banner at the top of the form summarizing what went wrong. The browser’s native popover doesn’t fit that design.
The correct approach is novalidate on the <form> element, plus about 25 lines of JavaScript. This is what form libraries provide as their core offering. Here it is without one:
<form id="contact" novalidate>
<div class="field">
<label for="email">Email</label>
<input id="email" name="email" type="email" required
aria-describedby="email-error">
<span id="email-error" class="field-error" role="alert"></span>
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" required minlength="10"
aria-describedby="message-error"></textarea>
<span id="message-error" class="field-error" role="alert"></span>
</div>
<button type="submit">Send</button>
</form>
const form = document.getElementById('contact')
form.addEventListener('submit', (e) => {
e.preventDefault()
clearErrors(form)
if (form.checkValidity()) {
// All constraints pass — hand the data to your own submit logic
// (a fetch() to your form endpoint, a form service, etc.)
submitForm(new FormData(form))
return
}
for (const field of form.elements) {
if (!field.name || field.checkValidity()) continue
const errorEl = document.getElementById(`${field.id}-error`)
if (errorEl) errorEl.textContent = field.validationMessage
}
// Move focus to the first invalid field (required for accessibility)
const firstInvalid = form.querySelector(':invalid')
firstInvalid?.focus()
})
function clearErrors(form) {
form.querySelectorAll('.field-error').forEach(el => { el.textContent = '' })
}
novalidate suppresses the browser’s native popover but does not disable constraint validation — checkValidity() still evaluates every constraint. You’re reading the results yourself and populating your custom elements.
This handles the full accessibility contract: aria-describedby associates each error span with its input, role="alert" causes screen readers to announce the error text when it appears, and moving focus to the first invalid field gives keyboard users a sensible landing point after a failed submission.
React Hook Form does this and adds an API layer, TypeScript types, field registration, and integration with controller-based components. For a contact form, you don’t need any of that. You need 25 lines.
inputmode and autocomplete: The Attributes That Actually Help on Mobile
These aren’t about validation — they’re about UX on mobile devices and autofill. They’re also completely ignored by form libraries, because they’re HTML attributes that don’t require JavaScript to work.
inputmode tells the browser which software keyboard to show without changing the input type or its validation semantics:
<!-- Numeric keypad (0-9 only), correct validation for ZIP codes -->
<input type="text" inputmode="numeric" pattern="[0-9]{5}" name="zip">
<!-- type="email" already implies the email keyboard — no inputmode needed -->
<input type="email" name="email">
<!-- Telephone keyboard -->
<input type="text" inputmode="tel" name="phone">
<!-- Decimal keyboard — for currency or measurements -->
<input type="text" inputmode="decimal" name="price">
The canonical case is ZIP codes. type="number" allows e and - (because scientific notation), has cross-browser edge cases for leading zeros, and doesn’t match what you want to validate. type="text" is semantically right but shows a full QWERTY keyboard on mobile. type="text" inputmode="numeric" pattern="[0-9]{5}" shows the numeric keyboard on mobile and validates correctly — the right combination that type="number" never was.
autocomplete maps your fields to the browser’s autofill database. The browser uses autocomplete attribute values — not field names or labels — to match inputs to stored data:
<input type="text" name="name" autocomplete="name">
<input type="email" name="email" autocomplete="email">
<input type="tel" name="phone" autocomplete="tel">
<input type="text" name="company" autocomplete="organization">
<input type="text" name="address" autocomplete="street-address">
<input type="text" name="city" autocomplete="address-level2">
<input type="text" name="zip" autocomplete="postal-code">
Getting these right is the difference between a form that takes 30 seconds to fill out on mobile and one that fills itself in with two taps. No library touches autocomplete — it’s purely an HTML concern.
What Actually Requires a Library
There’s a version of this argument that goes too far, so let me be precise about where native validation stops being enough.
You don’t need a library for:
- Contact forms (name, email, message)
- Newsletter signups
- Search bars and filter panels
- Login forms
- Simple address forms with a fixed set of fields
- Any form where the fields are known at build time and the validation rules don’t depend on each other
You do need a library for:
- Field arrays. “Add another address.” “Add another item.” React Hook Form’s
useFieldArrayhandles key management, ordering, and validation across a dynamic list of fields. Rebuilding this is a genuinely hard state management problem. - Dependent validation. “If the user selects ‘other,’ the text field below becomes required.” Simple cases are manageable — toggle
requiredin a change handler. Once you have three or four interdependent fields with cascading rules, the state machine gets complex enough that a library’s declarative schema is worth the weight. - Async validation. Checking email uniqueness against a server while the user types. The browser has no native concept for this. You need debounced fetches, abort controllers, loading states, and a way to block submission until the async check resolves.
- Multi-step wizards. Forms spread across multiple pages with back/forward navigation and accumulated state. The library is managing state across steps; the validation is a secondary concern.
- Schema-driven forms. Generating form UI dynamically from a server-supplied JSON schema. The library provides the compiler between schema and rendered fields.
The through-line: a library earns its place when the form is a complex UI component managing non-trivial state. A contact form is not a complex UI component. It collects a name, an email, and a message. The browser validates those three things natively, and has for over a decade.
One Note on Security
Client-side validation is UX, not security.
Whatever constraints you declare in HTML or JavaScript, a determined user can bypass them — disable JS, craft a raw HTTP request with curl, use DevTools to remove required attributes before submitting. The browser validation is a convenience layer that helps honest users fill in forms correctly and get fast feedback when something’s wrong. It is not an access control boundary.
Validate again on the server. Always. If you’re using a form service (Netlify Forms, Formspree, a custom edge function), check what they validate and what they pass through. Apply the same constraints server-side that you declared in HTML: required fields, format checks, length limits, and whatever is relevant to your data model.
This isn’t a strike against native validation — it’s equally true of React Hook Form and Zod on the frontend. z.string().email() is just as bypassable as type="email". The client layer gives users good UX. The server layer gives you data integrity. Neither substitutes for the other.
The reason form libraries became the default isn’t that HTML forms are bad at validation. It’s that :user-invalid didn’t exist when Formik launched in 2017, the native error popovers were hard to style, and React’s component model made it natural to manage form state in the same place as everything else. Those were real problems, and the libraries solved them.
The platform caught up. :user-invalid closed the CSS gap. The Constraint Validation API has been in every browser since 2012. setCustomValidity() handles custom messages. novalidate + checkValidity() handles custom UI. inputmode handles mobile keyboards. autocomplete handles autofill.
The libraries accumulated API surface and TypeScript bindings in the meantime, and now they look load-bearing. But if you strip away field arrays, async validation, and multi-step state management — features most contact forms don’t use — what’s left is a wrapper around things the browser already does.
The next time you’re about to npm install react-hook-form, ask whether the form actually has dynamic field arrays, async validation, or complex dependencies. If the answer is no — if it’s a contact form, a search bar, a login flow, a subscription signup — open package.json to the installed packages and close it again. The browser is already there.