Stop Installing Form Libraries. The Browser Can Do This.
JS Edition
Every React form tutorial starts the same way:
npm install react-hook-form
# or
npm install formik
# or
npm install yup zod @hookform/resolvers
Then you write 50 lines of boilerplate to validate an email field.
Here’s the thing: browsers have had powerful form validation APIs since 2012. And most developers have no idea they exist.
The Constraint Validation API
It’s been in every browser you care about for over a decade. And it does everything Formik does, but natively.
<form>
<input
type="email"
name="email"
required
minlength="5"
>
<button type="submit">Submit</button>
</form>
This already works. Try to submit an empty field or invalid email. The browser handles it.
“But I need custom error messages!” Sure:
const email = document.querySelector('input[type="email"]');
email.addEventListener('invalid', (e) => {
e.preventDefault(); // Stop default browser message
if (email.validity.valueMissing) {
email.setCustomValidity('Please enter your email');
} else if (email.validity.typeMismatch) {
email.setCustomValidity('Please enter a valid email address');
}
});
email.addEventListener('input', () => {
email.setCustomValidity(''); // Clear error on input
});
No library. No React context. No Yup schemas. Just the platform.
The ValidityState Object
Every form field has a .validity property with detailed validation state:
const input = document.querySelector('input');
input.validity.valid // true if all validations pass
input.validity.valueMissing // required field is empty
input.validity.typeMismatch // email/URL doesn't match format
input.validity.patternMismatch // doesn't match pattern attribute
input.validity.tooLong // exceeds maxlength
input.validity.tooShort // below minlength
input.validity.rangeOverflow // exceeds max
input.validity.rangeUnderflow // below min
input.validity.stepMismatch // doesn't fit step increments
input.validity.badInput // browser can't parse input
input.validity.customError // setCustomValidity was called
This is way more granular than most form libraries give you.
Real Example: Password Confirmation
Here’s something everyone implements manually:
<form id="signup">
<input
type="password"
id="password"
name="password"
required
minlength="8"
placeholder="Password"
>
<input
type="password"
id="confirm"
name="confirm"
required
placeholder="Confirm password"
>
<button type="submit">Sign Up</button>
</form>
<script>
const password = document.getElementById('password');
const confirm = document.getElementById('confirm');
function validatePassword() {
if (confirm.value === '') {
confirm.setCustomValidity('');
return;
}
if (confirm.value !== password.value) {
confirm.setCustomValidity('Passwords must match');
} else {
confirm.setCustomValidity('');
}
}
password.addEventListener('input', validatePassword);
confirm.addEventListener('input', validatePassword);
</script>
That’s it. No state management. No refs. Just native validation.
Custom Validators Without Libraries
Need a custom validator? Just check the condition and set the message:
const username = document.querySelector('#username');
username.addEventListener('input', async () => {
// Clear previous custom error
username.setCustomValidity('');
// First check built-in validations
if (!username.checkValidity()) {
return; // Let browser handle required, pattern, etc.
}
// Then check custom validation
const available = await checkUsernameAvailable(username.value);
if (!available) {
username.setCustomValidity('Username already taken');
username.reportValidity(); // Show error immediately
}
});
Async validation. With debouncing if you want. Without bringing in a form library.
The Methods Nobody Uses
checkValidity() - Silent validation check:
const form = document.querySelector('form');
if (form.checkValidity()) {
// All fields valid, proceed
submitToAPI(new FormData(form));
} else {
// Some fields invalid, but no error messages shown
}
reportValidity() - Check and show errors:
form.addEventListener('submit', (e) => {
e.preventDefault();
if (form.reportValidity()) {
// All valid, submit
submitToAPI(new FormData(form));
}
// If invalid, browser shows error messages
});
setCustomValidity() - Set custom error:
input.setCustomValidity('This specific thing is wrong');
input.reportValidity(); // Show the error
Critical: Always clear custom validity before re-checking:
input.addEventListener('input', () => {
input.setCustomValidity(''); // Clear old error
input.checkValidity(); // Re-validate
});
If you don’t clear it, the field stays invalid forever.
Styling with CSS Pseudo-Classes
The browser gives you these for free:
/* Required fields */
input:required {
border-left: 3px solid orange;
}
/* Optional fields */
input:optional {
border-left: 3px solid #ccc;
}
/* Valid input */
input:valid {
border-color: green;
}
/* Invalid input */
input:invalid {
border-color: red;
}
/* Only show invalid after user touches field */
input:invalid:not(:focus):not(:placeholder-shown) {
border-color: red;
}
/* Invalid and user has typed something */
input:user-invalid {
border-color: red;
background: #fee;
}
No JavaScript needed for visual feedback. The browser tracks validation state automatically.
Real-World Pattern: File Size Validation
<input type="file" id="avatar" accept="image/*">
<script>
const fileInput = document.getElementById('avatar');
fileInput.addEventListener('change', () => {
fileInput.setCustomValidity(''); // Clear previous error
const file = fileInput.files[0];
if (file && file.size > 2 * 1024 * 1024) { // 2MB
fileInput.setCustomValidity('File must be smaller than 2MB');
fileInput.reportValidity();
}
});
</script>
Can’t do this with HTML attributes alone. But the Constraint Validation API handles it cleanly.
Building a Reusable Validator
Here’s a pattern I use everywhere:
class FormValidator {
constructor(form) {
this.form = form;
this.validators = new Map();
this.form.addEventListener('submit', this.handleSubmit.bind(this));
}
addValidator(fieldName, validator) {
const field = this.form.elements[fieldName];
field.addEventListener('input', async () => {
field.setCustomValidity('');
if (!field.checkValidity()) {
return; // Let browser handle built-in validation
}
const error = await validator(field.value);
if (error) {
field.setCustomValidity(error);
}
});
}
async handleSubmit(e) {
e.preventDefault();
// Run all validations
for (const [name, validator] of this.validators.entries()) {
const field = this.form.elements[name];
const error = await validator(field.value);
if (error) {
field.setCustomValidity(error);
}
}
if (this.form.reportValidity()) {
// All valid, submit
const data = new FormData(this.form);
await this.onSubmit(data);
}
}
onSubmit(data) {
// Override this
}
}
// Usage
const validator = new FormValidator(document.querySelector('form'));
validator.addValidator('username', async (value) => {
const available = await checkUsername(value);
return available ? null : 'Username taken';
});
validator.addValidator('email', async (value) => {
const exists = await checkEmail(value);
return exists ? 'Email already registered' : null;
});
validator.onSubmit = async (data) => {
await fetch('/api/signup', {
method: 'POST',
body: data
});
};
70 lines. Handles async validation, custom errors, and form submission. No dependencies.
What React Hook Form Actually Does
Let’s be honest about what form libraries do:
Track field state - Which fields are dirty, touched, etc.
Run validations - Check values against rules
Display errors - Show validation messages
Handle submission - Prevent submit if invalid
The browser does #2, #3, and #4 natively. You only need state tracking for complex UX patterns.
For simple forms? You don’t need a library.
When Libraries Make Sense
Use React Hook Form / Formik when:
Complex multi-step forms with state that spans steps
Field values depend on other field values (dynamic forms)
You need granular control over when validation runs
You’re building form builders or dynamic schemas
Heavy validation logic that benefits from schema libraries (Zod/Yup)
Use native validation when:
Simple forms (login, signup, contact)
Standard field types (email, number, date)
You don’t need complex state management
Bundle size matters
You’re not using React
The Performance Difference
React Hook Form bundle: ~25kb (minified + gzipped)
Formik bundle: ~15kb
Constraint Validation API: 0kb (it’s built in)
Every form on every page loads this code. That adds up.
My Real-World Usage
I use Constraint Validation API for:
Authentication forms (login, signup, password reset)
Newsletter signups
Contact forms
Simple admin forms
Anywhere I don’t need React state management
I use React Hook Form for:
Multi-step wizards
Dynamic forms with conditional fields
Forms with complex interdependencies
When Zod schema validation is worth it
Most forms fall in the first category. But we reach for libraries by default.
The Missing Piece: Framework Integration
The one thing that’s actually hard - integrating with React’s render cycle:
function LoginForm() {
const formRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
if (formRef.current.reportValidity()) {
const data = new FormData(formRef.current);
// Submit
}
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input
type="email"
name="email"
required
onInvalid={(e) => {
e.preventDefault();
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Email required');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Invalid email');
}
}}
onInput={(e) => e.target.setCustomValidity('')}
/>
<button type="submit">Log In</button>
</form>
);
}
It works. It’s just more verbose than useForm() from React Hook Form.
But for simple forms? This is fine. And it’s 25kb lighter.
Browser Support
The Constraint Validation API has been supported since:
Chrome 10 (2011)
Firefox 4 (2011)
Safari 5 (2010)
Edge (all versions)
This is not cutting-edge. This is ancient, stable tech that just works.
My Take
Form libraries exist because developers don’t know the browser can do this.
I spent years installing Formik for forms that the Constraint Validation API could handle in 20 lines. Not because Formik was better, but because I didn’t know there was an alternative.
The API isn’t perfect. Custom error styling is CSS-only. TypeScript integration is manual. Framework integration is verbose.
But for most forms? You don’t need a library.
Start with HTML validation attributes and the Constraint Validation API. Reach for React Hook Form when you actually need state management and complex validation logic.
Not every form needs to be a React component with controlled inputs and custom hooks.
Sometimes the browser is enough.
Try it yourself:
<!DOCTYPE html>
<form>
<input
type="email"
required
pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$"
>
<button>Submit</button>
</form>
<script>
const input = document.querySelector('input');
input.addEventListener('invalid', (e) => {
e.preventDefault();
if (input.validity.valueMissing) {
alert('Please enter your email');
} else if (input.validity.patternMismatch) {
alert('Please enter a valid email');
}
});
</script>
Save as HTML. Open in browser. See validation work. No npm install needed.


