Difficulty: Beginner
How do you handle forms and validation in React? What patterns and libraries are commonly used?
Forms and validation in React. This is one of those topics where the simple version is easy, but real-world forms get complicated fast. Let me walk you through the progression, from basic to production-grade, so you understand not just the 'how' but the 'why' behind each approach.
Let's start with the basics. At its simplest, a React form is just a bunch of controlled inputs wired up to state. You have a useState for each field (or a single useState with an object), onChange handlers to update state, and an onSubmit handler that reads the final values and does something with them. For a login form with email and password, this is totally fine. Maybe 20-30 lines of code.
But then things start getting complicated. You need validation. OK, so you add a validate function that checks each field and returns an errors object. But when do you validate? You have three choices, and each has different UX implications.
Validation on change (every keystroke): gives instant feedback but can be annoying. Imagine typing the first letter of your email and immediately seeing 'Invalid email format'. The user hasn't even finished typing yet. This approach works well for fields with simple rules (minimum length, max characters) but poorly for complex patterns (email, URLs).
Validation on blur (when the user leaves the field): this is the sweet spot for most forms. The user finishes typing, clicks away, and then sees whether the field is valid. It's not as aggressive as on-change, and it catches errors before the user hits submit.
Validation on submit: the simplest to implement. You wait until the user hits the submit button, validate everything at once, and show all errors. The downside is that the user might fill out a long form, hit submit, and get hit with a wall of error messages. Not a great experience.
The best approach in production is usually a combination: validate on blur for inline feedback, and re-validate on submit as a safety net. You also want 'touched' tracking. A field is 'touched' once the user has interacted with it and moved away. You only show errors for touched fields, so the form doesn't start screaming at the user before they've even tried filling it out.
Now, here's where it gets real. You have a registration form with 8 fields, including a 'confirm password' field that needs to match the password, an email that needs to be checked against the server to see if it's already taken (async validation), and a dynamic 'skills' section where the user can add and remove items. Managing all of this with raw useState quickly becomes a nightmare of scattered state updates, validation logic interleaved with UI code, and edge cases around when to show errors.
This is where form libraries come in, and they're not a luxury; they're a necessity for any non-trivial form.
React Hook Form is the most popular choice, and for good reason. It uses uncontrolled inputs internally (refs instead of state), which means it doesn't re-render your component on every keystroke. You register each input with the register function, which returns the necessary props (ref, onChange, onBlur, name). It handles validation, error tracking, touched state, submission, and even loading state out of the box. The API is clean and the performance is excellent.
The killer combo in modern React is React Hook Form + Zod. Zod is a schema validation library that lets you define the shape and rules of your data as a JavaScript object. You create a schema like z.object({ email: z.string().email(), password: z.string().min(8) }), and you plug it into React Hook Form with zodResolver. Now your form validation is declarative, type-safe (Zod infers TypeScript types from the schema), and the exact same schema can be reused on your server for API validation. This is huge. You define your validation rules once and use them on both client and server, so they can never get out of sync.
Let me share some real-world best practices that I've learned from building a lot of forms:
- Always disable the submit button while the form is submitting. Otherwise users double-click and you get duplicate submissions. React Hook Form gives you isSubmitting for free.
- Handle server-side errors gracefully. Your client validation might pass, but the server might reject the data (duplicate email, expired session, etc.). Show those errors inline next to the relevant field, not just as a generic toast.
- For accessibility, connect your error messages to inputs using aria-invalid and aria-describedby. Screen readers need to know which field has an error and what the error message says. Most form libraries support this, but it often requires explicit wiring.
- Think about form state persistence. If a user accidentally navigates away from a half-filled form, should they lose everything? For long forms (like job applications), consider saving the form state to localStorage or sessionStorage so they can pick up where they left off.
- For dynamic arrays of fields (like adding multiple addresses or skills), React Hook Form's useFieldArray hook is a lifesaver. It handles adding, removing, and reordering items in a form array with proper key management.
A common interview pattern is to show the progression: 'For a simple login form, I'd use manual useState. For anything with more than 3-4 fields or complex validation, I'd reach for React Hook Form with Zod. And I'd make sure the Zod schema is shared with the server for consistent validation.' That shows pragmatism, awareness of trade-offs, and full-stack thinking.
function RegistrationForm() {
const [form, setForm] = useState({
name: '', email: '', password: ''
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
function validate(values) {
const errs = {};
if (!values.name.trim()) errs.name = 'Name is required';
if (!values.email.includes('@')) errs.email = 'Valid email required';
if (values.password.length < 8) errs.password = 'Min 8 characters';
return errs;
}
function handleChange(e) {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
// Clear error on change
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
}
function handleBlur(e) {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
const errs = validate(form);
setErrors(errs);
}
function handleSubmit(e) {
e.preventDefault();
const errs = validate(form);
setErrors(errs);
setTouched({ name: true, email: true, password: true });
if (Object.keys(errs).length === 0) {
console.log('Submitting:', form);
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<input name="name" value={form.name}
onChange={handleChange} onBlur={handleBlur} />
{touched.name && errors.name && <span>{errors.name}</span>}
</div>
<div>
<input name="email" value={form.email}
onChange={handleChange} onBlur={handleBlur} />
{touched.email && errors.email && <span>{errors.email}</span>}
</div>
<div>
<input name="password" type="password" value={form.password}
onChange={handleChange} onBlur={handleBlur} />
{touched.password && errors.password && <span>{errors.password}</span>}
</div>
<button type="submit">Register</button>
</form>
);
}
Manual form handling with touched tracking shows errors only after user interacts with a field. This is the fundamental pattern all form libraries abstract.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Schema can be shared with server-side validation
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords must match',
path: ['confirmPassword'],
});
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
resolver: zodResolver(schema),
mode: 'onBlur',
});
async function onSubmit(data) {
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(data),
});
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button disabled={isSubmitting}>Register</button>
</form>
);
}
React Hook Form with Zod provides type-safe validation, minimal re-renders, and a schema that can be reused on the server. zodResolver bridges the two libraries.
function useForm(initialValues, validate) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
function handleChange(e) {
const { name, value, type, checked } = e.target;
setValues(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
}
function handleBlur(e) {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
if (validate) setErrors(validate(values));
}
async function handleSubmit(onSubmit) {
return async (e) => {
e.preventDefault();
const errs = validate ? validate(values) : {};
setErrors(errs);
const allTouched = Object.keys(values).reduce(
(acc, key) => ({ ...acc, [key]: true }), {}
);
setTouched(allTouched);
if (Object.keys(errs).length === 0) {
setIsSubmitting(true);
await onSubmit(values);
setIsSubmitting(false);
}
};
}
return {
values, errors, touched, isSubmitting,
handleChange, handleBlur, handleSubmit,
};
}
// Usage
function ContactForm() {
const { values, errors, touched, handleChange, handleBlur, handleSubmit } =
useForm({ name: '', message: '' }, (vals) => {
const errs = {};
if (!vals.name) errs.name = 'Required';
if (!vals.message) errs.message = 'Required';
return errs;
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input name="name" value={values.name}
onChange={handleChange} onBlur={handleBlur} />
{touched.name && errors.name && <span>{errors.name}</span>}
<textarea name="message" value={values.message}
onChange={handleChange} onBlur={handleBlur} />
<button type="submit">Send</button>
</form>
);
}
A custom useForm hook extracts common form patterns into reusable logic. This demonstrates understanding of both forms and custom hooks.
Forms, Validation, Error Handling, Form Libraries