React form submissions without React Hook Form
How to handle React forms without a validation library - plain HTML5 + the Constraint Validation API + a hosted backend. Zero dependencies, zero bundle bloat.
React Hook Form is a great library and you don't need it for a contact form. Most React form examples in 2026 reach for a 27 KB validation library to handle three required inputs and an email field - features that have been built into HTML for over a decade. If your form is "name, email, message," there's a leaner path that uses what the browser already does well.
This post walks through that path: HTML5 validation + the Constraint Validation API + a hosted form backend = zero new dependencies, zero new bundle bytes, and a form that works whether JavaScript loads or not.
The "everyone uses React Hook Form" cargo cult
React Hook Form is excellent at what it's designed for: complex forms with lots of fields, conditional logic, async validation, integration with Zod or Yup schema libraries. For those use cases, the library earns its weight.
For a 4-field contact form, it's overkill. The bundle cost is real (~27 KB minified), the cognitive cost is real (a layer between you and the actual form state), and the operational cost shows up as "we have a forms framework, let's use it for everything" creep.
Let's solve the 4-field contact form without it.
Step 1: HTML5 validation does most of the work
Browsers have required, type="email", pattern, minlength, maxlength, min, max. They render error messages, prevent submission on invalid input, and focus the first invalid field. No JavaScript needed - the MDN forms guide is the canonical reference for what every modern browser ships out of the box.
function ContactForm() {
return (
<form action="https://formspring.io/f/abc123" method="POST">
<label>
Name
<input name="name" required minLength={2} />
</label>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Message
<textarea name="message" required minLength={10} />
</label>
<button>Send</button>
</form>
);
}
Submit invalid input. The browser prevents submission, focuses the offending field, and shows a localized error tooltip. No state management, no JavaScript bundle.
Step 2: The Constraint Validation API for inline errors
The default browser tooltip is fine but doesn't always match your design system. The Constraint Validation API gives you programmatic access to the same validation state to render inline errors.
import { useState } from 'react';
function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
function onInvalid(e: React.InvalidEvent<HTMLInputElement | HTMLTextAreaElement>) {
e.preventDefault();
setErrors(prev => ({ ...prev, [e.currentTarget.name]: e.currentTarget.validationMessage }));
}
function onChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
if (e.currentTarget.validity.valid) {
setErrors(prev => {
const { [e.currentTarget.name]: _, ...rest } = prev;
return rest;
});
}
}
return (
<form action="https://formspring.io/f/abc123" method="POST">
<label>
Name
<input name="name" required minLength={2} onInvalid={onInvalid} onChange={onChange} />
{errors.name && <span className="error">{errors.name}</span>}
</label>
<label>
Email
<input name="email" type="email" required onInvalid={onInvalid} onChange={onChange} />
{errors.email && <span className="error">{errors.email}</span>}
</label>
<label>
Message
<textarea name="message" required minLength={10} onInvalid={onInvalid} onChange={onChange} />
{errors.message && <span className="error">{errors.message}</span>}
</label>
<button>Send</button>
</form>
);
}
That's it. ~40 lines for inline validation. No library. Works without JavaScript (form submits via HTML); works with JavaScript (inline errors render as the user types).
Bundle size: ~0 net bytes added - you'd ship the React state hook regardless.
Step 3: AJAX submission with progressive enhancement
Default form submission causes a page navigation. For modern UX, you usually want an inline thank-you state instead, posted with the standard Fetch API.
function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
// ... onInvalid / onChange from above ...
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('sending');
const res = await fetch(e.currentTarget.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(e.currentTarget),
});
if (res.ok) {
setStatus('sent');
} else if (res.status === 422) {
// Server-side validation rejected something the browser missed
const { errors: serverErrors } = await res.json();
setErrors(serverErrors);
setStatus('idle');
} else {
setStatus('error');
}
}
if (status === 'sent') {
return <p className="thanks">Thanks - we'll reply within a day.</p>;
}
return (
<form
action="https://formspring.io/f/abc123"
method="POST"
onSubmit={onSubmit}
>
{/* ... fields ... */}
<button disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send'}
</button>
{status === 'error' && <p className="error">Something broke. Try again?</p>}
</form>
);
}
The form's action attribute is still set, so if JavaScript fails to load (or the user has it disabled), the form submits via standard POST and the visitor follows a server-side redirect. Progressive enhancement.
What about server-side validation?
The browser-side validation is cosmetic - anyone with curl can bypass required. The real contract is server-side. Hosted form backends like Formspring let you configure a server-side schema (required fields, type, min/max, pattern) that returns 422 with field-level errors when violated.
The 422 response shape:
{
"errors": {
"email": ["Must be a valid email"],
"message": ["Must be at least 10 characters"]
}
}
Your client-side handler renders those errors inline (the example above does this). The server is the source of truth; the client-side validation is UX polish on top.
When you do need a library
Three real cases where React Hook Form (or Formik, or TanStack Form) earns its weight:
- Conditional fields based on other field values. "If country is Germany, show VAT ID." Possible without a library but the library makes it cleaner.
- Async validation. "Check whether this username is taken before submitting." HTML5 doesn't do async; you need state management.
- Field arrays. "Add another email recipient" with arbitrary count. Library helpers handle the array indexing cleanly.
- Schema-driven forms. Generating fields from a Zod schema or JSON spec. Library integration here pays off.
For a contact form? None of those apply. Use what HTML gives you.
The minimal vs maximal patterns side by side
| Concern | Minimal (HTML5 + fetch) | Maximal (React Hook Form + Zod) |
|---|---|---|
| Bundle size | ~0 | ~30 KB |
| Lines of code | ~40 | ~80 |
| Works without JS | Yes | Submits via action, but validation gone |
| Works without TS | Yes | Yes |
| Async validation | No | Yes |
| Conditional fields | Possible | Easier |
| Schema-driven generation | No | Yes |
| Error UX | Inline + tooltip fallback | Full control |
For 95% of React contact forms, the minimal pattern wins on bundle, simplicity, and progressive-enhancement story.
What about Server Actions?
Next.js Server Actions are an interesting middle ground - you write server-side validation logic that runs in your Next.js codebase, and the form's action is a function reference instead of a URL. Useful when you want validation in your own code but don't want to maintain a full API endpoint.
'use server';
import { z } from 'zod';
const ContactSchema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
export async function submit(formData: FormData) {
const parsed = ContactSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
await fetch('https://formspring.io/f/abc123', {
method: 'POST',
body: formData,
});
}
You get TypeScript-checked schema validation at the edge of your Next.js app, and the actual submission storage stays in the hosted form backend. No API route, no client-side library.
The takeaway
Reach for React Hook Form when the form is genuinely complex. For a contact form on a marketing site, plain HTML5 + the Constraint Validation API + a hosted backend gets you 95% of the value at 0% of the bundle cost.
Related from this desk
- How to receive form submissions in Next.js without a backend - the App Router-specific patterns when the same React form lives inside Next.
- JAMstack contact form: the complete 2026 guide - the cross-framework comparison behind picking the hosted-endpoint path.
- SvelteKit contact form without a server route - the same zero-server-code pattern in a different framework.
- Static-site contact form checklist: shipping right in 2026 - the launch checklist you should run before pushing this to production.
- Product side: form backend.
Drop one URL into your form's action, layer the Constraint Validation API for inline errors if you want polish, and you're shipping. Formspring's free tier - 50 submissions/month, no credit card.
Written by Florian Wartner
Florian Wartner
Founder of Formspring and Pixel & Process. Senior full-stack engineer based in Lübeck, Germany. Building developer-first SaaS with EU data residency and honest pricing.
Elsewhere