React contact form without a form library
A React contact form does not need controlled inputs, and it does not need a form library. The fields can stay in the DOM. On React 18 the only thing worth tracking is what the submission is doing:
import { useState } from "react";
const ENDPOINT = "https://api.formroute.dev/f/site_xyz";
export function ContactForm() {
const [status, setStatus] = useState("idle");
async function handleSubmit(event) {
event.preventDefault();
setStatus("submitting");
try {
const response = await fetch(ENDPOINT, {
method: "POST",
body: new FormData(event.currentTarget),
});
setStatus(response.ok ? "success" : "error");
} catch {
setStatus("error");
}
}
if (status === "success") return <p>Thanks, we got it.</p>;
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={status === "submitting"}>
{status === "submitting" ? "Sending…" : "Send"}
</button>
{status === "error" && <p role="alert">Something went wrong. Try again.</p>}
</form>
);
}
One useState, no onChange handlers, no object holding field values.
On React 19 the state goes away too
React 19 ships form actions in plain React, not only in Next.js. A <form action={fn}> accepts an async function, useActionState tracks the result, and useFormStatus reads the pending state from inside the form. The version above collapses:
import { useActionState } from "react";
const ENDPOINT = "https://api.formroute.dev/f/site_xyz";
async function send(_previous, formData) {
const response = await fetch(ENDPOINT, { method: "POST", body: formData });
return response.ok ? { ok: true } : { error: "Something went wrong. Try again." };
}
export function ContactForm() {
const [state, action, pending] = useActionState(send, null);
if (state?.ok) return <p>Thanks, we got it.</p>;
return (
<form action={action}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={pending}>{pending ? "Sending…" : "Send"}</button>
{state?.error && <p role="alert">{state.error}</p>}
</form>
);
}
Two things to know before you copy it. useActionState calls the action as (previousState, formData), so the leading parameter is not optional even when you ignore it. And React resets an uncontrolled form after a successful action, which is what you want here and a surprise if you expected the fields to persist.
| Version | Hook | Import | Signature |
|---|---|---|---|
| React 19 | useActionState |
react |
(previousState, formData) |
| React 18 | useFormState |
react-dom |
(previousState, formData) |
| React 18, no actions | useState |
react |
your own onSubmit |
The same hook is what the Next.js contact form uses, where static export decides whether you get a server at all.
Why the inputs are uncontrolled
new FormData(form) reads every named input at submit time, straight from the DOM. Controlled state is optional because the browser was already storing the values, and copying them into React on every keystroke buys nothing unless something reacts to them while typing.
It also means adding a field is a one-line change to the markup. With controlled inputs, a new field is a new state key, a new handler and a new line in the payload.
The name attribute is what matters. FormData keys come from name, not id, and a field without one is silently dropped. That single fact accounts for most submissions that arrive with a field missing.
The four states a submission has
The bug in most hand-rolled forms is treating submission as a boolean. It has four states, and each needs different markup:
| State | What the user should see |
|---|---|
idle |
The form |
submitting |
The form, disabled, with a button that says so |
success |
Confirmation, not the form again |
error |
The form, still filled in, with a message they can act on |
The last row is the one people get wrong. If a request fails and you clear or unmount the form, the visitor retypes everything and usually leaves instead. Uncontrolled inputs help here for free: the DOM still holds what they typed, so a retry costs one click.
When a field does need state
Reach for state on a field only when something outside it depends on its value while typing: a character counter, a live preview, a field that enables another, validation shown before submit, or a draft saved to localStorage.
Even then, control that one field. There is no rule that a form is all-controlled or all-uncontrolled.
Skip the form library
React Hook Form and its peers exist for real problems: dozens of fields, arrays of repeated groups, cross-field validation, wizards. A contact form has none of those. A dependency added to avoid writing eleven lines only looks like a good trade on the day you make it.
Server-side validation is not optional either, but it is not React’s job: browser attributes vanish for anything that is not your component, so the rules have to hold at the endpoint. What a form backend does with them covers that half.
The component is the easy half
Everything above is about getting a clean payload out of the browser. What happens to it afterwards is where a contact form actually succeeds or fails: whether the message survives spam filtering, whether it reaches a person, and whether you can still find it next month.
FormRoute is the piece that takes it from there. Access is private while the first sites come on, so request an invite if you want an endpoint to point this at.