React Forms and Validation: Production Patterns
March 11, 2025 · 8 min read
React, Forms, Validation, React Hook Form, Interview Prep
Introduction
Forms are essential for most applications - user registration, data entry, search, filters. While React makes basic forms straightforward, production forms need validation, error handling, and good UX. This article covers patterns from basic controlled forms to React Hook Form + Zod solutions.
Controlled vs Uncontrolled Forms
Controlled: React Owns the State
function ControlledForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
login({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
);
}
Uncontrolled: DOM Owns the State
function UncontrolledForm() {
const emailRef = useRef();
const passwordRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
login({
email: emailRef.current.value,
password: passwordRef.current.value,
});
};
return (
<form onSubmit={handleSubmit}>
<input type="email" ref={emailRef} defaultValue="" />
<input type="password" ref={passwordRef} defaultValue="" />
<button type="submit">Login</button>
</form>
);
}
When to Use Which
Controlled: When you need real-time validation, conditional fields, or derived values. Uncontrolled: For simple forms where you only need values on submit. React Hook Form uses uncontrolled by default for performance, but gives you controlled-like DX.
Manual Validation
Before introducing libraries, understand the basics:
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validate = () => {
const newErrors = {};
if (!email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(email)) {
newErrors.email = 'Invalid email format';
}
if (!password) {
newErrors.password = 'Password is required';
} else if (password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
await login({ email, password });
} catch (err) {
setErrors({ form: 'Invalid credentials' });
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
{errors.form && <div className="error">{errors.form}</div>}
<div>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
Problems with Manual Forms
- Boilerplate: State for every field
- Validation logic: Manual, error-prone
- Performance: Every keystroke re-renders
- Complex forms: Nested objects, arrays become unwieldy
React Hook Form
React Hook Form uses uncontrolled components by default, minimizing re-renders while providing a powerful API.
import { useForm } from 'react-hook-form';
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm();
const onSubmit = async (data) => {
await login(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Invalid email format'
}
})}
type="email"
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<input
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters'
}
})}
type="password"
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
Watch and Conditional Fields
function RegistrationForm() {
const { register, watch } = useForm();
const accountType = watch('accountType');
return (
<form>
<select {...register('accountType')}>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
{/* Conditional field */}
{accountType === 'business' && (
<input
{...register('companyName', { required: 'Company name is required' })}
placeholder="Company Name"
/>
)}
</form>
);
}
Zod Schema Validation
Zod provides TypeScript-first schema validation with excellent type inference.
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const loginSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format'),
password: z
.string()
.min(1, 'Password is required')
.min(8, 'Password must be at least 8 characters')
});
// TypeScript type is inferred!
type LoginFormData = z.infer<typeof loginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema)
});
const onSubmit = (data: LoginFormData) => {
// data is fully typed and validated
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Same as before */}
</form>
);
}
Advanced Zod Patterns
// Password confirmation
const passwordSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword']
});
// Conditional validation
const userSchema = z.object({
role: z.enum(['user', 'admin']),
adminCode: z.string().optional()
}).refine(
(data) => data.role !== 'admin' || data.adminCode,
{
message: 'Admin code required for admin role',
path: ['adminCode']
}
);
// Transform values
const formSchema = z.object({
price: z.string().transform((val) => parseFloat(val)),
date: z.string().transform((val) => new Date(val))
});
// Coerce types (for form inputs)
const querySchema = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(10)
});
Dynamic Forms with Field Arrays
import { useForm, useFieldArray } from 'react-hook-form';
function RecipeForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: {
name: '',
ingredients: [{ name: '', amount: '' }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'ingredients'
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Recipe name" />
<h3>Ingredients</h3>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`ingredients.${index}.name`)}
placeholder="Ingredient name"
/>
<input
{...register(`ingredients.${index}.amount`)}
placeholder="Amount"
/>
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', amount: '' })}>
Add Ingredient
</button>
<button type="submit">Save Recipe</button>
</form>
);
}
Use field.id for Keys
Always use field.id as the key, not the array index. React Hook Form generates stable IDs that prevent bugs when reordering or removing items.
Field Array Lessons
Building dynamic forms in Glucoplate, I learned the hard way about field.id vs index. Index-based keys caused input focus to jump unexpectedly when removing items from the middle. Always use the stable ID that React Hook Form provides - it exists for a reason.
Reusable Form Components
import { useFormContext } from 'react-hook-form';
function Input({ name, label, ...props }) {
const {
register,
formState: { errors }
} = useFormContext();
const error = errors[name];
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input
id={name}
{...register(name)}
{...props}
aria-invalid={!!error}
/>
{error && (
<span className="error-message" role="alert">
{error.message}
</span>
)}
</div>
);
}
// Usage with FormProvider
import { FormProvider, useForm } from 'react-hook-form';
function ContactForm() {
const methods = useForm();
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<Input name="name" label="Name" placeholder="Your name" />
<Input name="email" label="Email" type="email" />
<Input name="message" label="Message" />
<button type="submit">Send</button>
</form>
</FormProvider>
);
}
Handling Server Errors
function RegistrationForm() {
const { register, handleSubmit, setError, formState: { errors } } = useForm();
const onSubmit = async (data) => {
try {
await registerUser(data);
} catch (err) {
// Handle field-specific errors from server
if (err.response?.data?.field) {
setError(err.response.data.field, {
message: err.response.data.message
});
} else {
// General form error
setError('root', {
message: err.message || 'Something went wrong'
});
}
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && (
<div className="form-error">{errors.root.message}</div>
)}
{/* fields */}
</form>
);
}
Interview Questions
Q1: Controlled vs uncontrolled components - when to use each?
Answer:
| Controlled | Uncontrolled |
|---|---|
| Real-time validation | Less re-renders |
| Instant field updates | Simpler for basic forms |
| Full control over input | Better performance |
| More boilerplate | Less control |
Use controlled for complex forms with dependencies between fields. Use uncontrolled (or React Hook Form) for performance-critical forms.
Q2: How does React Hook Form improve performance?
Answer:
- Uncontrolled inputs: No state updates on keystroke
- Isolated re-renders: Only components subscribed to specific fields re-render
- Proxy-based subscriptions: Fine-grained reactivity
Typing in one field doesn't re-render the entire form.
Q3: How do you handle form validation with Zod?
Answer:
- Define schema with Zod
- Use
zodResolverwith React Hook Form - Types are inferred automatically
Benefits: Single source of truth for types and validation, runtime validation matches TypeScript types.
Q4: How do you handle async validation?
Answer:
// With Zod refine
const schema = z.object({
email: z.string().email()
}).refine(async (data) => {
const exists = await checkEmailExists(data.email);
return !exists;
}, {
message: 'Email already taken',
path: ['email']
});
// Or with React Hook Form validate
register('username', {
validate: async (value) => {
const available = await checkUsername(value);
return available || 'Username taken';
}
});
Common Mistakes
1. Forgetting valueAsNumber
// ❌ WRONG - value is string
<input type="number" {...register('age')} />
// ✅ CORRECT - value is number
<input type="number" {...register('age', { valueAsNumber: true })} />
2. Validating on Every Keystroke
// ❌ Can be annoying UX
useForm({ mode: 'onChange' });
// ✅ Better - validate on blur, then onChange after error
useForm({ mode: 'onBlur' });
// or
useForm({ mode: 'onTouched' });
3. Missing Key in Field Arrays
// ❌ WRONG - using index as key
fields.map((field, index) => (
<div key={index}>...</div> // Causes issues when reordering
))
// ✅ CORRECT - using field.id
fields.map((field, index) => (
<div key={field.id}>...</div> // Stable identity
))
Summary
- React Hook Form provides performance and great DX
- Zod enables type-safe validation with inference
- useFieldArray handles dynamic form fields
- FormProvider enables nested form components
- Validation modes affect UX (onSubmit, onBlur, onChange)
- Handle server errors with
setError
Next up: State Management with Context, Redux Toolkit, and Zustand.
Part 6 of the React Developer Reference series.