Skip to content

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.

Updated 8 min read 1,451 words By Florian Wartner

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.

tsx
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.

tsx
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.

tsx
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:

json
{
  "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.

tsx
'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

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.

From the field

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.

Give your next important form a real home.

Start free with one form. Add ownership, private files, and clear history before responses pile up in inboxes.

·· no card · 50 submissions / mo · no countdown