Connecting a React form to a backend
Short answer: Keep form state in React, validate on submit, POST JSON to the endpoint with fetch, and render the response as a status message. The backend validates again; client validation is only for speed of feedback.
React makes form state explicit, which is both the benefit and the trap: it is easy to write a form that validates only in the browser and trusts its own output.
The form
import { useState } from "react";
type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "error"; message: string };
export function ContactForm() {
const [status, setStatus] = useState<Status>({ kind: "idle" });
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form));
if (typeof data.email !== "string" || !data.email.includes("@")) {
setStatus({ kind: "error", message: "Enter a valid email address." });
return;
}
setStatus({ kind: "sending" });
try {
const response = await fetch("https://formivo.faizyab.me/api/f/YOUR_FORM_ID", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(data),
});
const body = await response.json();
if (!response.ok) throw new Error(body?.error?.message ?? "Submission failed");
form.reset();
setStatus({ kind: "sent" });
} catch (error) {
setStatus({ kind: "error", message: (error as Error).message });
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit" disabled={status.kind === "sending"}>
{status.kind === "sending" ? "Sending…" : "Send"}
</button>
{status.kind === "error" && <p role="alert">{status.message}</p>}
{status.kind === "sent" && <p role="status">Thanks — we will reply shortly.</p>}
</form>
);
}Uncontrolled is usually enough
The example above is uncontrolled: the DOM holds the values and `FormData` reads them on submit. Use controlled inputs only when a value drives rendering, a character counter, dependent fields, or live validation. Controlled everything means a re-render per keystroke for no benefit.
Validate twice, on purpose
| Layer | Job | Can be bypassed? |
|---|---|---|
| HTML attributes | Free, accessible hints | Yes — trivially |
| React check on submit | Fast feedback, better copy | Yes — devtools or curl |
| Backend validation | The actual guarantee | No |
Formivo rejects oversized bodies, malformed JSON, too many fields, values over 10,000 characters and unsupported content types regardless of what the client sent.
Common React pitfalls
- Reading `event.target` after an await — the synthetic event is no longer current. Capture `currentTarget` first.
- Forgetting `preventDefault`, so the browser navigates away mid-submit.
- Not disabling the button during the request, which invites duplicate submissions. Idempotency keys are the server-side safety net.
- Rendering an error inside a div that screen readers never announce. Use `role="alert"`.
If you are on a framework with server actions or loaders, those are preferable for app-internal forms. For marketing pages, the code above plus a real endpoint is all you need.