Articles › React
Forms in React: Controlled Inputs, Validation and React Hook Form
Build robust React forms: controlled vs uncontrolled inputs, validation and error messages, submitting to an API, React Hook Form with Zod, accessibility and handling server errors.
Forms are where users give your application data — registration, checkout, contact, admin panels. They're also where the most UX frustration happens: lost input, unclear errors, double submissions. This guide covers building forms in React properly, from plain controlled inputs to React Hook Form with schema validation.
Controlled vs uncontrolled inputs
Controlled — React state is the source of truth:
function NewsletterForm() {
const [email, setEmail] = useState("");
return <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />;
}
Uncontrolled — the DOM keeps the value; you read it when needed:
function SearchForm({ onSearch }) {
const handleSubmit = (e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
onSearch(data.get("q"));
};
return (
<form onSubmit={handleSubmit}>
<input name="q" defaultValue="" />
</form>
);
}
Controlled inputs make it easy to validate as the user types, format values or enable/disable buttons. Uncontrolled inputs re-render less and work beautifully with FormData for simple forms. React Hook Form uses uncontrolled inputs internally for performance.
A complete controlled form
const initial = { name: "", email: "", message: "" };
function validate(values) {
const errors = {};
if (!values.name.trim()) errors.name = "Please enter your name.";
if (!/^\S+@\S+\.\S+$/.test(values.email)) errors.email = "Enter a valid email address.";
if (values.message.trim().length < 20) errors.message = "Message must be at least 20 characters.";
return errors;
}
function ContactForm() {
const [values, setValues] = useState(initial);
const [touched, setTouched] = useState({});
const [status, setStatus] = useState("idle"); // idle | sending | sent | error
const errors = validate(values);
const isValid = Object.keys(errors).length === 0;
const handleChange = (e) => setValues((v) => ({ ...v, [e.target.name]: e.target.value }));
const handleBlur = (e) => setTouched((t) => ({ ...t, [e.target.name]: true }));
async function handleSubmit(e) {
e.preventDefault();
setTouched({ name: true, email: true, message: true });
if (!isValid || status === "sending") return;
setStatus("sending");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error();
setStatus("sent");
setValues(initial);
setTouched({});
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<Field label="Name" name="name" value={values.name} error={touched.name && errors.name}
onChange={handleChange} onBlur={handleBlur} />
<Field label="Email" name="email" type="email" value={values.email} error={touched.email && errors.email}
onChange={handleChange} onBlur={handleBlur} dir="ltr" />
<Field label="Message" name="message" as="textarea" value={values.message}
error={touched.message && errors.message} onChange={handleChange} onBlur={handleBlur} />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send message"}
</button>
{status === "sent" && <p role="status">Thanks! I'll get back to you soon.</p>}
{status === "error" && <p role="alert">Something went wrong. Please try again.</p>}
</form>
);
}
UX details that matter:
- Show errors after the user leaves a field (
touched) or on submit — not while they're still typing their first character. - Disable the submit button while sending to prevent double submissions.
- Keep the user's input on errors; clear it only after success.
- Confirm success clearly.
An accessible field component
function Field({ label, name, error, as: As = "input", ...props }) {
const id = useId();
const errorId = `${id}-error`;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<As id={id} name={name} aria-invalid={Boolean(error)} aria-describedby={error ? errorId : undefined} {...props} />
{error && <span id={errorId} className="field__error">{error}</span>}
</div>
);
}
- Every input needs a
<label>(placeholders are not labels). aria-invalidandaria-describedbylet screen readers announce errors.- Use the right
type(email,tel,number) andautoCompletevalues so mobile keyboards and password managers help users.
React Hook Form + Zod
For larger forms, React Hook Form handles registration, validation, touched state and submission with minimal re-renders, and Zod describes validation rules once:
npm install react-hook-form zod @hookform/resolvers
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z
.object({
email: z.string().email("Enter a valid email."),
password: z.string().min(8, "At least 8 characters."),
confirm: z.string(),
acceptTerms: z.literal(true, { errorMap: () => ({ message: "You must accept the terms." }) }),
})
.refine((d) => d.password === d.confirm, { path: ["confirm"], message: "Passwords don't match." });
function RegisterForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm({ resolver: zodResolver(schema), mode: "onBlur" });
const onSubmit = async (data) => {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.status === 409) {
setError("email", { message: "This email is already registered." }); // server error on a field
return;
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input type="email" autoComplete="email" {...register("email")} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<input type="password" autoComplete="new-password" {...register("password")} />
{errors.password && <span role="alert">{errors.password.message}</span>}
<input type="password" autoComplete="new-password" {...register("confirm")} />
{errors.confirm && <span role="alert">{errors.confirm.message}</span>}
<label><input type="checkbox" {...register("acceptTerms")} /> I accept the terms</label>
{errors.acceptTerms && <span role="alert">{errors.acceptTerms.message}</span>}
<button disabled={isSubmitting}>{isSubmitting ? "Creating account…" : "Register"}</button>
</form>
);
}
Benefits: typed form data (z.infer<typeof schema>), consistent validation, async submission state, and setError for mapping server-side errors (like ASP.NET Core ValidationProblemDetails) back to fields.
Mapping ASP.NET Core validation errors
if (res.status === 400) {
const problem = await res.json(); // { errors: { Email: ["..."], Password: ["..."] } }
for (const [field, messages] of Object.entries(problem.errors ?? {})) {
setError(field.charAt(0).toLowerCase() + field.slice(1), { message: messages[0] });
}
}
Client-side validation improves UX; server-side validation is mandatory — never trust the client.
Special inputs
- Files: use
<input type="file" accept="image/*">, show previews withURL.createObjectURL, validate size client-side and send withFormData. - Numbers and money: store as strings while editing; parse on submit to avoid cursor jumps and locale issues.
- Persian input: normalize Persian/Arabic digits (
۰-۹,٠-٩) to Latin before validation, and setdir="ltr"for emails, phones and URLs. - Dates: use a Jalali date picker for Persian users and send ISO dates to the API.
Common mistakes
- Showing every error on page load.
- No loading state → users click "submit" five times.
- Losing all input after a server error.
- Placeholder-only labels (bad for accessibility, disappear on typing).
- Validating only on the client.
Summary
Use controlled inputs for interactive validation and uncontrolled ones (or FormData) for simple forms, show errors at the right moment, prevent double submissions, keep forms accessible with labels and ARIA attributes, and reach for React Hook Form + Zod as forms grow. Map server validation errors back to fields, and always validate on the server too.
FAQ
Do I need a form library?
Not for small forms. For long forms, dynamic fields, complex validation or many forms across an app, React Hook Form saves a lot of code.
onChange or onBlur validation?
onBlur (then live updates once a field has an error) is a friendly default; onSubmit suits short forms.
How do I prevent double submission?
Disable the button and track a submitting state; on the server, idempotency keys protect critical operations like payments.