Handling Form Validation Without Losing Your Sanity
If you have ever built a complex form in React, you know the struggle. What starts as a simple login screen quickly spirals into a nightmare of useState, 50-line validation functions, and confusing useEffect hooks.
Managing form state manually is one of the quickest ways to bloat your component and hurt performance. The good news? You don't have to do it that way.
Here is how to handle form validation efficiently, cleanly, and without tearing your hair out.
1. The "Old Way": The Controlled Component Trap
The traditional React approach often looks like this:
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
if (!name) setErrors(prev => ({ ...prev, name: 'Required' }));
// ... more manual logic
};
Why this hurts:
- Re-renders: Every keystroke updates state, causing the entire form to re-render. On large forms, this feels laggy.
- Boilerplate: You write the same change handlers and error states over and over.
- Coupled Logic: Your UI code is tightly mixed with your validation rules.
2. The Solution: React Hook Form
The industry standard for modern React forms is React Hook Form.
Unlike the traditional approach, React Hook Form uses uncontrolled components. It registers your inputs using ref, meaning the form state is managed outside of the React render cycle. This results in significantly better performance and less code.
The Magic of register
Instead of writing onChange and value for every input, you just "register" it.
<input {...register("firstName")} />
3. Don't Validate Manually—Use a Schema (Zod)
Writing if (email.includes('@')) is prone to bugs. Instead, use a schema validation library like Zod.
Zod allows you to define the shape of your data separately from your UI. It reads like plain English.
import { z } from "zod";
const signUpSchema = z.object({
username: z.string().min(3, "Username must be at least 3 chars"),
email: z.string().email("Invalid email address"),
age: z.number().min(18, "You must be 18 or older"),
});
4. Bringing It Together
By combining React Hook Form with Zod (via @hookform/resolvers), you get the best of both worlds: performant inputs and robust validation.
The "Sanity-Saving" Pattern
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// 1. Define your schema
const schema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Password must be 8+ characters"),
});
const LoginForm = () => {
// 2. Hook up the form with the Zod resolver
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
resolver: zodResolver(schema),
});
const onSubmit = (data) => {
// This only runs if validation passes!
console.log("Form Data:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="form-stack">
{/* Email Field */}
<div>
<label>Email</label>
<input {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
{/* Password Field */}
<div>
<label>Password</label>
<input type="password" {...register("password")} />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<button disabled={isSubmitting} type="submit">
{isSubmitting ? "Loading..." : "Login"}
</button>
</form>
);
};
5. UX Best Practices
Even with great tools, user experience matters.
- Validate on Blur: Users hate being yelled at while they are still typing. Configure your form to validate
onBlur(when they leave the field) oronChange(only after the first error appears).useForm({ mode: 'onBlur', resolver: zodResolver(schema) }) - Disable Double Submission: Use the
isSubmittingstate provided by the hook to disable the button while the API call is in progress. - Accessibility: Ensure your error messages are connected to inputs using
aria-describedbyso screen readers can announce them.
Comments
Sign in to join the conversation