AJAX form validation: common mistakes and how to fix them

You wire up an AJAX form. Submit it. It works. You ship it.
Then a user emails you saying the form didn't submit. Another says they got a weird error. Another says the submit button is stuck in a loading state. All of these have the same root: something in the validation or error-handling path went wrong in a way your local testing didn't catch.
These are the mistakes that cause most of them.
Mistake 1: returning HTML instead of JSON
The most common cause of broken validation UI is an API that returns HTML in its error responses.
You submit the form. The request returns a 400. Your JavaScript tries to display the error and renders something like <p class="error">Email is required.</p> as a literal string, HTML tags and all.
Or you use dangerouslySetInnerHTML to make it look right. Either way, you're working around a problem that shouldn't exist.
The fix is to always return structured JSON from your API:
// Always return JSON, never HTML strings
return Response.json(
{ success: false, errors: { email: "Email address is required." } },
{ status: 400 }
);Your frontend can then map each error to the right field without any string parsing:
if (!response.ok) {
const { errors } = await response.json();
setFieldErrors(errors); // { email: "Email address is required." }
}There's a longer explanation of why this matters and how to structure error responses in stop returning HTML in your API responses.
Mistake 2: client-side validation as the only validation
Client-side validation gives users immediate feedback without a round trip. It's good UX. But it's not a security layer.
A user can submit your form directly with curl or a tool like Postman. Your JavaScript validation never runs. Whatever they send goes straight to your server.
# Your required-field check and email format validation didn't run
curl -X POST https://yoursite.com/api/contact \
-F "email=notvalid" \
-F "message="Server-side validation is not a backup for when client-side fails. It's the actual validation. Client-side is a UX improvement on top of it.
The practical pattern is to write your validation logic once on the server and optionally reuse it on the client:
// lib/validate-contact.ts
export function validateContact(data: {
email: string;
message: string;
}): Record<string, string> {
const errors: Record<string, string> = {};
if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = "A valid email address is required.";
}
if (!data.message || data.message.trim().length < 10) {
errors.message = "Message must be at least 10 characters.";
}
return errors;
}If you're using a library like Zod, define the schema once and use it in both places. The schema is the contract. The client-side check is a convenience.
Never use client-side validation results to decide what to do in your API handler. Re-validate on the server every time, even if the client already checked.
Mistake 3: not handling async errors correctly
AJAX form handling introduces a category of bugs that synchronous code doesn't have: timing issues.
Duplicate submissions: the user clicks submit, the request takes two seconds, they click again. Without protection, you now have two submissions in flight. A simple guard in your submit handler prevents this:
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (submitting) return; // guard against double-click
setSubmitting(true);
try {
// ... fetch request
} finally {
setSubmitting(false); // always reset, even on error
}
}The finally block is important. If you only reset submitting in the success path, a network error leaves the form permanently locked.
Unhandled network errors: fetch doesn't throw on 4xx or 5xx responses. It only throws if the request itself fails (offline, DNS failure, CORS block). If you're not explicitly checking response.ok, a 500 from your server looks identical to a 200:
// This is wrong -- response.ok could be false (400, 500, etc.)
const data = await response.json();
setSuccess(true);
// This is correct
if (!response.ok) {
const { errors } = await response.json();
setErrors(errors);
return;
}
setSuccess(true);Race conditions from fast double-submits: if two requests are in flight, whichever response arrives second wins, regardless of which request was sent second. The submitting guard above prevents this. If you have a case where the guard isn't enough, cancel the previous request with AbortController before sending a new one.
Mistake 4: poor error UX
The error handling works technically but users can't use it effectively.
No field-level mapping: displaying all errors in a single block at the top of the form makes the user scroll up, read through the list, figure out which field each error applies to, and scroll back down. Error messages next to the relevant input are more usable:
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
{errors.email && (
<p role="alert" className="text-red-500 text-sm">
{errors.email}
</p>
)}
</div>Messages that don't help: "Invalid input" tells the user nothing. "A valid email address is required, for example name@domain.com" tells them exactly what to fix. The bar for a useful error message: can the user read it and know what to change without guessing?
Not resetting errors on resubmit: if a user fixes one field and resubmits, the previous errors for already-fixed fields should clear. Reset your error state before each submission attempt:
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setErrors({}); // clear previous errors before new attempt
setSubmitting(true);
// ...
}A working implementation
Here's a contact form that handles all four of these correctly:
// components/ContactForm.tsx
"use client";
import { useState } from "react";
type FieldErrors = Record<string, string>;
export function ContactForm() {
const [errors, setErrors] = useState<FieldErrors>({});
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (submitting) return;
setSubmitting(true);
setErrors({});
const formData = new FormData(e.currentTarget);
try {
const res = await fetch("/api/contact", {
method: "POST",
body: formData,
});
const json = await res.json();
if (!res.ok) {
setErrors(
json.errors ?? { _form: "Something went wrong. Please try again." }
);
return;
}
setSubmitted(true);
} catch {
setErrors({
_form:
"Could not reach the server. Check your connection and try again.",
});
} finally {
setSubmitting(false);
}
}
if (submitted) {
return <p>Message sent. We'll be in touch.</p>;
}
return (
<form onSubmit={handleSubmit} noValidate>
{errors._form && <p role="alert">{errors._form}</p>}
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
{errors.email && <p role="alert">{errors.email}</p>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" />
{errors.message && <p role="alert">{errors.message}</p>}
</div>
<button type="submit" disabled={submitting}>
{submitting ? "Sending..." : "Send message"}
</button>
</form>
);
}A few things worth noting: noValidate disables browser-native validation so you control the UX completely. The _form key is a convention for request-level errors not attached to a specific field. The try/catch wrapping the fetch handles actual network failures separately from API errors.
For what to do on the server side, see 8 common mistakes developers make with contact forms, which covers server-side validation and rate limiting. For designing a more scalable validation architecture, see how to design a scalable form validation system.
Want validation and submission handling in one place?
Formtorch handles validation, storage, and error responses for every form submission.

