React Forms Best Practices
Overview
This guide covers form handling in React using React Hook Form with Zod validation, focusing on patterns like payment forms, account creation, and multi-step wizards.
Forms are critical to applications - they're where users provide data, make transactions, and interact with business logic. Poorly implemented forms lead to user frustration, data errors, and abandoned workflows.
Why React Hook Form?
React Hook Form solves the performance and complexity problems of traditional React form libraries.
The problem with controlled inputs:
Traditional React forms use controlled inputs where every keystroke updates state, causing the entire form to re-render:
// BAD: Traditional approach: Re-renders entire form on every keystroke
function PaymentForm() {
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState('');
const [vendor, setVendor] = useState('');
// Every keystroke triggers re-render of entire form
return (
<form>
<input value={amount} onChange={(e) => setAmount(e.target.value)} />
<input value={currency} onChange={(e) => setCurrency(e.target.value)} />
<input value={vendor} onChange={(e) => setVendor(e.target.value)} />
</form>
);
}
With 10 fields, every keystroke in any field re-renders all 10 fields. This causes performance issues in large forms.
React Hook Form's solution:
Uses uncontrolled inputs (refs) and only re-renders specific fields when necessary:
// GOOD: React Hook Form: Minimal re-renders
function PaymentForm() {
const { register, handleSubmit } = useForm();
// Fields are uncontrolled, no state updates on keystroke
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('amount')} />
<input {...register('currency')} />
<input {...register('vendor')} />
</form>
);
}
Benefits:
- Performance: Minimal re-renders, fast even with hundreds of fields
- Less boilerplate: No manual state management for each field
- Built-in validation: Integrates with validation libraries (Zod, Yup)
- TypeScript support: Full type inference for form data
- Field arrays: Dynamic fields (add/remove) built-in
- DevTools: Browser extension for debugging form state
Integration with Zod:
Zod provides runtime type validation that matches your TypeScript types, creating a single source of truth:
const schema = z.object({
amount: z.number().positive(),
currency: z.string().length(3)
});
type FormData = z.infer<typeof schema>; // TypeScript type derived from schema
Core Principles
1. React Hook Form for Performance
Use uncontrolled inputs via register() to minimize re-renders. React Hook Form tracks field values internally using refs, only triggering re-renders for validation errors or form submission.
When you need controlled behavior (watching field values for conditional logic), use watch() selectively:
const amount = watch('amount'); // Only subscribes to amount changes
2. Zod for Type-Safe Validation
Define validation rules using Zod schemas. This provides runtime validation that matches your TypeScript types, catching errors before submission.
Why Zod:
- Type inference: TypeScript types automatically generated from schema
- Composable: Combine schemas with
.merge(),.extend(),.pick() - Transformations: Parse and transform input (
z.coerce.number()) - Custom validation:
.refine()for complex business rules
3. Validate on Submit
Don't validate on every keystroke - this creates poor UX and performance issues. Validate on blur (when user leaves field) or on submit.
useForm({
mode: 'onBlur' // Validate when user leaves field
});
Validation modes:
onSubmit(default): Validate only when form submittedonBlur: Validate when field loses focusonChange: Validate on every change (use sparingly)onTouched: Validate on blur, then on change after first interaction
4. Clear Error Messages
Error messages should guide users to fix the problem, not just state what's wrong.
// BAD: Technical, unhelpful
z.string().min(1) // "String must contain at least 1 character(s)"
// GOOD: Clear, actionable
z.string().min(1, 'Vendor ID is required')
z.number().positive('Amount must be greater than zero')
5. Disable Submit During Processing
Prevent duplicate submissions by disabling the submit button while processing:
<button disabled={isSubmitting}>
{isSubmitting ? 'Processing...' : 'Submit'}
</button>
6. Reset Forms After Success
Clear form state after successful submission using reset():
const onSubmit = async (data) => {
await createPayment(data);
reset(); // Clear all fields
};
React Hook Form Setup
Installation
npm install react-hook-form zod @hookform/resolvers
Basic Form
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// Define validation schema
const paymentSchema = z.object({
amount: z.number().positive('Amount must be positive').max(1000000, 'Amount too large'),
currency: z.string().length(3, 'Currency must be 3 characters'),
vendorId: z.string().min(1, 'Vendor ID is required'),
description: z.string().optional()
});
type PaymentFormData = z.infer<typeof paymentSchema>;
export function PaymentForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset
} = useForm<PaymentFormData>({
resolver: zodResolver(paymentSchema),
defaultValues: {
amount: 0,
currency: 'USD',
vendorId: '',
description: ''
}
});
const onSubmit = async (data: PaymentFormData) => {
try {
await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
reset(); // Clear form on success
alert('Payment created successfully');
} catch (error) {
console.error('Failed to create payment:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="amount">Amount</label>
<input
id="amount"
type="number"
step="0.01"
{...register('amount', { valueAsNumber: true })}
/>
{errors.amount && <span className="error">{errors.amount.message}</span>}
</div>
<div>
<label htmlFor="currency">Currency</label>
<select id="currency" {...register('currency')}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
{errors.currency && <span className="error">{errors.currency.message}</span>}
</div>
<div>
<label htmlFor="vendorId">Vendor ID</label>
<input id="vendorId" type="text" {...register('vendorId')} />
{errors.vendorId && <span className="error">{errors.vendorId.message}</span>}
</div>
<div>
<label htmlFor="description">Description (optional)</label>
<textarea id="description" {...register('description')} />
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Payment'}
</button>
</form>
);
}
Advanced Validation
Conditional Validation
Conditional validation applies different rules based on other field values. Use Zod's .refine() for cross-field validation.
How it works: .refine() receives the entire form data object and returns true (valid) or false (invalid). The path option specifies which field should show the error.
const transferSchema = z.object({
transferType: z.enum(['internal', 'external']),
accountId: z.string().min(1, 'Account ID required'),
amount: z.number().positive('Amount must be positive'),
routingNumber: z.string().optional()
}).refine(
// Validation function: receives entire form data
(data) => {
if (data.transferType === 'external') {
// External transfers require routing number
return !!data.routingNumber && data.routingNumber.length === 9;
}
// Internal transfers don't need routing number
return true;
},
{
message: 'Routing number must be 9 digits for external transfers',
path: ['routingNumber'] // Error shows on routingNumber field
}
);
Multiple refinements:
Chain multiple .refine() calls for separate conditional validations:
const paymentSchema = z.object({
amount: z.number().positive(),
paymentMethod: z.enum(['card', 'ach', 'wire']),
cardNumber: z.string().optional(),
accountNumber: z.string().optional(),
wireDetails: z.string().optional()
})
.refine(
(data) => data.paymentMethod !== 'card' || !!data.cardNumber,
{ message: 'Card number required for card payments', path: ['cardNumber'] }
)
.refine(
(data) => data.paymentMethod !== 'ach' || !!data.accountNumber,
{ message: 'Account number required for ACH payments', path: ['accountNumber'] }
)
.refine(
(data) => data.paymentMethod !== 'wire' || !!data.wireDetails,
{ message: 'Wire details required for wire transfers', path: ['wireDetails'] }
);
Alternative: Discriminated unions (more type-safe):
const paymentSchema = z.discriminatedUnion('paymentMethod', [
z.object({
paymentMethod: z.literal('card'),
amount: z.number().positive(),
cardNumber: z.string().min(1, 'Card number required')
}),
z.object({
paymentMethod: z.literal('ach'),
amount: z.number().positive(),
accountNumber: z.string().min(1, 'Account number required')
}),
z.object({
paymentMethod: z.literal('wire'),
amount: z.number().positive(),
wireDetails: z.string().min(1, 'Wire details required')
})
]);
Async Validation
Async validation checks data against external sources (database, API) to verify uniqueness, existence, or validity.
Important: Zod schemas are synchronous. For async validation, validate manually in the submit handler using setError().
const accountSchema = z.object({
accountNumber: z.string().min(1, 'Account number required')
});
function AccountForm() {
const { register, handleSubmit, setError, formState: { errors } } = useForm({
resolver: zodResolver(accountSchema)
});
const onSubmit = async (data: { accountNumber: string }) => {
// Check if account exists (async validation)
const response = await fetch(`/api/accounts/${data.accountNumber}`);
if (!response.ok) {
setError('accountNumber', {
type: 'manual',
message: 'Account not found'
});
return;
}
// Proceed with submission
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('accountNumber')} />
{errors.accountNumber && <span>{errors.accountNumber.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
Field Dependencies
Watch Field Values
function TransferForm() {
const { register, watch } = useForm();
const transferType = watch('transferType');
const amount = watch('amount');
// Calculate fee based on type and amount
const fee = transferType === 'external'
? amount * 0.03
: 0;
return (
<form>
<select {...register('transferType')}>
<option value="internal">Internal</option>
<option value="external">External</option>
</select>
<input type="number" {...register('amount', { valueAsNumber: true })} />
<div>Fee: ${fee.toFixed(2)}</div>
{transferType === 'external' && (
<input
placeholder="Routing Number"
{...register('routingNumber')}
/>
)}
</form>
);
}
Controller for Custom Components
import { Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';
function PaymentScheduleForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="scheduledDate"
control={control}
rules={{ required: 'Date is required' }}
render={({ field, fieldState }) => (
<div>
<DatePicker
selected={field.value}
onChange={field.onChange}
minDate={new Date()}
/>
{fieldState.error && <span>{fieldState.error.message}</span>}
</div>
)}
/>
<button type="submit">Schedule Payment</button>
</form>
);
}
Multi-Step Forms
Wizard Pattern
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Step schemas
const step1Schema = z.object({
accountId: z.string().min(1),
amount: z.number().positive()
});
const step2Schema = z.object({
vendorId: z.string().min(1),
vendorName: z.string().min(1)
});
const step3Schema = z.object({
scheduledDate: z.date(),
notes: z.string().optional()
});
// Combined schema
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FormData = z.infer<typeof fullSchema>;
export function PaymentWizard() {
const [step, setStep] = useState(1);
const { register, handleSubmit, formState: { errors }, trigger, getValues } = useForm<FormData>({
resolver: zodResolver(fullSchema),
mode: 'onTouched'
});
const nextStep = async () => {
let isValid = false;
// Validate current step fields
if (step === 1) {
isValid = await trigger(['accountId', 'amount']);
} else if (step === 2) {
isValid = await trigger(['vendorId', 'vendorName']);
}
if (isValid) {
setStep(step + 1);
}
};
const onSubmit = async (data: FormData) => {
console.log('Final submission:', data);
// Submit to API
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<div>
<h2>Step 1: Account & Amount</h2>
<input placeholder="Account ID" {...register('accountId')} />
{errors.accountId && <span>{errors.accountId.message}</span>}
<input type="number" placeholder="Amount" {...register('amount', { valueAsNumber: true })} />
{errors.amount && <span>{errors.amount.message}</span>}
<button type="button" onClick={nextStep}>Next</button>
</div>
)}
{step === 2 && (
<div>
<h2>Step 2: Vendor Details</h2>
<input placeholder="Vendor ID" {...register('vendorId')} />
{errors.vendorId && <span>{errors.vendorId.message}</span>}
<input placeholder="Vendor Name" {...register('vendorName')} />
{errors.vendorName && <span>{errors.vendorName.message}</span>}
<button type="button" onClick={() => setStep(1)}>Back</button>
<button type="button" onClick={nextStep}>Next</button>
</div>
)}
{step === 3 && (
<div>
<h2>Step 3: Schedule</h2>
<input type="date" {...register('scheduledDate', { valueAsDate: true })} />
{errors.scheduledDate && <span>{errors.scheduledDate.message}</span>}
<textarea placeholder="Notes" {...register('notes')} />
<button type="button" onClick={() => setStep(2)}>Back</button>
<button type="submit">Submit Payment</button>
</div>
)}
</form>
);
}
Form Arrays (Dynamic Fields)
Adding/Removing Fields
import { useFieldArray } from 'react-hook-form';
const batchPaymentSchema = z.object({
payments: z.array(
z.object({
vendorId: z.string().min(1),
amount: z.number().positive()
})
).min(1, 'At least one payment required')
});
type BatchPaymentData = z.infer<typeof batchPaymentSchema>;
function BatchPaymentForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<BatchPaymentData>({
resolver: zodResolver(batchPaymentSchema),
defaultValues: {
payments: [{ vendorId: '', amount: 0 }]
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'payments'
});
const onSubmit = (data: BatchPaymentData) => {
console.log('Batch payment:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input
placeholder="Vendor ID"
{...register(`payments.${index}.vendorId`)}
/>
<input
type="number"
placeholder="Amount"
{...register(`payments.${index}.amount`, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>Remove</button>
{errors.payments?.[index]?.vendorId && (
<span>{errors.payments[index]?.vendorId?.message}</span>
)}
</div>
))}
<button type="button" onClick={() => append({ vendorId: '', amount: 0 })}>
Add Payment
</button>
<button type="submit">Submit Batch</button>
</form>
);
}
Error Handling
Display Error Summary
function PaymentFormWithSummary() {
const { register, handleSubmit, formState: { errors } } = useForm();
const errorCount = Object.keys(errors).length;
return (
<form onSubmit={handleSubmit(console.log)}>
{errorCount > 0 && (
<div className="error-summary">
<h3>Please fix the following errors:</h3>
<ul>
{Object.entries(errors).map(([field, error]) => (
<li key={field}>
{field}: {error?.message as string}
</li>
))}
</ul>
</div>
)}
{/* Form fields */}
</form>
);
}
Further Reading
React Framework Guidelines
- React General - React fundamentals and hooks
- React State Management - Form state with Zustand and server state with React Query
- React Testing - Testing forms and user interactions
Cross-Cutting Guidelines
- TypeScript General - TypeScript best practices
- TypeScript Types - Type-safe form validation
- OpenAPI Frontend Integration - Type-safe API clients for form submission
- Input Validation - Validation strategies and security
- Web Accessibility - Accessible form patterns
External Resources
Summary
Key Takeaways
- React Hook Form for performance - uncontrolled inputs minimize re-renders
- Zod for type-safe validation - runtime validation matches TypeScript types
- Validate on blur or submit - don't validate every keystroke
- Clear error messages - guide users to fix validation errors
- Disable submit during processing - prevent duplicate submissions
- Reset forms after success - clear form state
- Watch for field dependencies - show/hide fields based on other values
- Controller for custom components - integrate third-party inputs
- Multi-step forms validate per step before advancing
- Field arrays for dynamic form fields (batch operations)
Next Steps: Review React Testing for testing forms and React State Management for form state patterns.