React form submissions without React Hook Form

Florian Wartner2026-05-07 7 min read

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.

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.

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:

  1. Conditional fields based on other field values. "If country is Germany, show VAT ID." Possible without a library but the library makes it cleaner.
  2. Async validation. "Check whether this username is taken before submitting." HTML5 doesn't do async; you need state management.
  3. Field arrays. "Add another email recipient" with arbitrary count. Library helpers handle the array indexing cleanly.
  4. 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.

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.

Florian Wartner

Founder of Formspring and Pixel & Process. Senior Laravel and Vue engineer based in Lübeck, Germany. Building developer-first SaaS with EU data residency and honest pricing.

Ship your form in two minutes.

No credit card. 50 free submissions a month, every month.