React Forms: From Vanilla to Production-Ready
Forms look simple until you build one. Then come validation rules, error states, loading indicators, and edge cases.
This guide starts with a basic form and progressively adds functionality. Each section builds on the last, highlighting exactly what changes.
The Base Form
We’ll build a registration form with name, email, and password fields. Right now it does nothing. The button submits and refreshes the page:
Note: The
components.jsandstyles.cssfiles won’t change throughout this guide. They’re hidden in later examples so you can focus on the form logic.
We’ll explore different ways to make this form work.
1. Uncontrolled Forms
In uncontrolled forms, React doesn’t manage the input values. The browser handles them natively, and we only read the values on submit using FormData. This is the simplest approach.
First, add state for errors and a submit handler that extracts values and validates them:
export default function RegistrationForm() { const [errors, setErrors] = useState({});
function handleSubmit(e) { e.preventDefault(); const formData = new FormData(e.currentTarget); const values = { name: formData.get("name"), email: formData.get("email"), password: formData.get("password"), };
const newErrors = validateForm(values); if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; }
e.currentTarget.reset(); // Reset form setErrors({}); // Clear errors setTimeout(() => alert(`Welcome, ${values.name}!`), 300); // Mimics API submission }
return ( <form className="form"> <form onSubmit={handleSubmit} className="form"> {/* ... */} </form> );}Now display error messages below each input:
export default function RegistrationForm() { const [errors, setErrors] = useState({});
function handleSubmit(e) { // ... }
return ( <form onSubmit={handleSubmit} className="form"> <input name="name" placeholder="Name" className="input" /> {errors.name && <span className="error">{errors.name}</span>} <input name="email" placeholder="Email" className="input" /> {errors.email && <span className="error">{errors.email}</span>} <PasswordInput name="password" /> {errors.password && <span className="error">{errors.password}</span>} <button type="submit" className="button">Create Account</button> </form> );}How it works:
FormDataextracts values on submit, no need to track every keystrokevalidateForm()returns{ field: "message" }for invalid fields (or empty object if valid)- Only
errorsneeds state, React re-renders to display them when validation fails
This approach is minimal with just one useState and a submit handler. Note that users won’t see errors until they click submit, and form doesn’t get re-validated until they submit again.
View the full code below and try it out:
In the next section, we’ll add live feedback so errors update as users type.
2. Controlled Forms
In controlled forms, React manages the input values through state. Every keystroke updates state, and the input displays whatever state holds. This gives us full control to validate and show errors while typing. We’ll start from the base form again. Error rendering stays the same, but we need more state and handlers.
First, add state for form values and track which fields the user has interacted with:
export default function RegistrationForm() { const [formData, setFormData] = useState({ name: "", email: "", password: "" }); const [touched, setTouched] = useState({}); const [errors, setErrors] = useState({});
// ...}Next, add handlers for change and blur events:
export default function RegistrationForm() { // ... state
function handleChange(e) { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value }));
if (touched[name]) { setErrors(prev => ({ ...prev, [name]: validateField(name, value) })); } }
function handleBlur(field) { setTouched(prev => ({ ...prev, [field]: true })); setErrors(prev => ({ ...prev, [field]: validateField(field, formData[field]) })); }
// ...}handleChange updates the value on every keystroke. If the field was already touched, it also re-validates so users see errors clear as they fix them.
handleBlur runs when users leave a field. This is where we show errors for the first time, because showing them immediately while typing feels aggressive. Once a field is touched, handleChange takes over validation on subsequent keystrokes.
Now that we have the handlers, wire them to the inputs. Each input needs value, onChange, and onBlur:
export default function RegistrationForm() { // ... state and handlers
return ( <form onSubmit={handleSubmit} className="form"> <input name="name" value={formData.name} onChange={handleChange} onBlur={() => handleBlur("name")} placeholder="Name" className="input" /> {touched.name && errors.name && <span className="error">{errors.name}</span>} {/* ... other fields */} </form> );}What changed from Section 1:
- React now controls input values through state (controlled inputs)
- Added
touchedstate to track which fields have been visited - Validation happens on blur and re-validates on change
- Errors clear immediately as users fix them
View the full code and try it out:
This works, but managing three state objects and custom handlers gets tedious. In the next section, we’ll use React Hook Form to handle all of this for us.
3. React Hook Form
React Hook Form handles all that boilerplate. No more useState for values, errors, or touched. The useForm hook does it all.
Step 1: Replace manual state with useForm:
// No more: useState for formData, errors, touched, handleChange, handleBlur...const { register, handleSubmit, formState: { errors, isSubmitting }, reset,} = useForm({ mode: "onTouched", // Validate when user leaves the field defaultValues: { name: "", email: "", password: "" },});Step 2: Replace custom handlers with register. Validation rules are defined inline with each field:
<input placeholder="Name" className="input" {...register("name", { required: "Name is required", minLength: { value: 2, message: "Name must be at least 2 characters" }, })}/>{errors.name && <span className="error">{errors.name.message}</span>}Step 3: Use handleSubmit wrapper:
<form onSubmit={onSubmit} className="form"><form onSubmit={handleSubmit(onSubmit)} className="form"> {/* ... */} <button type="submit">Create Account</button> <button type="submit" disabled={isSubmitting}>Create Account</button></form>What changed from Section 2:
- No
useStatefor form data, errors, or touched - No
handleChangeorhandleBlurfunctions - Validation rules defined inline with each field
isSubmittingbuilt-in for loading states
React Hook Form supports required, minLength, maxLength, pattern (for regex), and custom validate functions. Check the full code for email and password examples:
This approach is cleaner and we don’t have to manage form states manually. But inline validation rules can get out of hand for large forms. In the next section, we’ll use Zod to keep validation logic in one place.
4. React Hook Form + Zod
For complex forms, you can use Zod to define all validation rules in one schema. This keeps validation logic centralized instead of scattered across each field.
First, define your schema in a separate file:
import { z } from "zod";
export const userSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), email: z.string().email("Invalid email format"), password: z .string() .min(8, "Password must be at least 8 characters") .regex(/[A-Z]/, "Must contain an uppercase letter") .regex(/[0-9]/, "Must contain a number"),});Notice how
.email()handles email validation without writing regex manually. Zod has many built-in validators like.url(),.uuid(), and more.
Now update the previous React Hook Form example. Add the new imports and pass zodResolver to useForm:
import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { PasswordInput } from "./components";import { userSchema } from "./schema";
export default function RegistrationForm() { const { register, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(userSchema), mode: "onTouched", });
// ...}Now register no longer needs inline validation rules. The schema handles everything:
<input placeholder="Name" {...register("name", { required: "Name is required", minLength: { value: 2, message: "Name must be at least 2 characters" }, })} {...register("name")}/>What changed from Section 3:
- Validation rules moved from inline to a centralized schema
- Built-in validators like
.email()replace manual regex patterns registercalls are cleaner with no validation options- TypeScript types can be inferred with
z.infer<typeof userSchema>
View the full code and try it out:
All the validation we’ve done so far happens in the browser before data is sent to the server. But users can bypass this by disabling JavaScript or sending requests directly to your API. For real security, the server needs to validate data too.
5. Client + Server Validation
We’ll build on Section 4 by adding server-side validation. This gives us instant feedback from the client and security from the server.
First, create a function that validates with the same Zod schema. This simulates a server call. In production, you’d use a Next.js Server Function or API endpoint:
// actions.js (add "use server" at top for Next.js Server Actions)import { userSchema } from "./schema";
export async function submitUserForm(data) { const result = userSchema.safeParse(data);
if (!result.success) { const errors = {}; result.error.issues.forEach((issue) => { errors[issue.path[0]] = issue.message; }); return { success: false, errors, data: null }; }
// Simulate database save delay await new Promise((r) => setTimeout(r, 300));
return { success: true, errors: null, data: result.data, message: "Account created successfully!", };}Now update the React Hook Form component to call this action:
export default function RegistrationForm() { const [serverState, setServerState] = useState(null);
const { register, handleSubmit, formState: { errors, isSubmitting }, formState: { errors: clientErrors, isSubmitting }, reset, } = useForm({ resolver: zodResolver(userSchema), mode: "onTouched" });
async function onSubmit(data) { const state = await submitUserForm(data);
if (state.success) { alert(state.message); reset(); setServerState(null); } else { setServerState(state); } }
// Merge client and server errors for unified display const serverErrors = serverState?.errors || {}; const allErrors = { ...Object.fromEntries( Object.entries(clientErrors).map(([key, error]) => [key, error.message]) ), ...serverErrors, };
return ( <form onSubmit={handleSubmit(onSubmit)} className="form"> {/* ... fields with updated error display */} </form> );}Finally, update error rendering to use allErrors instead of errors:
export default function RegistrationForm() { // ... state, useForm, onSubmit, allErrors from above
return ( <form onSubmit={handleSubmit(onSubmit)} className="form"> <input placeholder="Name" className="input" {...register("name")} /> {errors.name?.message && <span className="error">{errors.name.message}</span>} {allErrors.name && <span className="error">{allErrors.name}</span>}
<input placeholder="Email" className="input" {...register("email")} /> {errors.email?.message && <span className="error">{errors.email.message}</span>} {allErrors.email && <span className="error">{allErrors.email}</span>}
<PasswordInput {...register("password")} /> {errors.password?.message && <span className="error">{errors.password.message}</span>} {allErrors.password && <span className="error">{allErrors.password}</span>}
<button type="submit" disabled={isSubmitting} className="button"> {isSubmitting ? "Validating..." : "Create Account"} </button> </form> );}allErrors combines client and server errors so they appear inline with fields. This works well when server errors map to specific fields (like “email already taken”). For general errors that don’t map to a field (network issues, rate limits), a toast or banner works better.
What changed from Section 4:
- Added
serverStateto store the server response onSubmitnow calls the server action and handles success/failure- Errors from both client and server are merged into one object
- The same Zod schema validates on both sides
View the full code and try it out:
More setup than simpler approaches, but this is what production forms look like.
When to Use What
| Approach | Good For |
|---|---|
| Uncontrolled | Quick prototypes, simple forms where UX isn’t critical |
| Controlled | Learning React, forms with unusual validation timing |
| React Hook Form | Most forms (the sensible default) |
| RHF + Zod | Complex validation, shared schemas, TypeScript projects |
| Client + Server | Production apps, anything with a database |
Start with React Hook Form. Add Zod when validation gets complex. Add server validation when you’re persisting data.
Key Points
- Uncontrolled forms are the simplest. Use
FormDataon submit when you don’t need live feedback - Validate on blur, re-validate on change. Show errors when users leave a field, clear them as they fix
- React Hook Form handles the boilerplate. No manual state, handlers, or touched tracking
- Zod centralizes validation and gives you TypeScript types from the same schema
- Client validation is for UX, server validation is for security. You need both in production
Forms are one of those things where the “right” approach depends entirely on context. A contact form on a landing page doesn’t need the same rigor as a multi-step checkout flow. Pick the simplest approach that meets your needs, and add complexity only when you hit its limits.
← Back to blog