Skip to main content

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 submitted
  • onBlur: Validate when field loses focus
  • onChange: 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​

Cross-Cutting Guidelines​

External Resources​


Summary​

Key Takeaways​

  1. React Hook Form for performance - uncontrolled inputs minimize re-renders
  2. Zod for type-safe validation - runtime validation matches TypeScript types
  3. Validate on blur or submit - don't validate every keystroke
  4. Clear error messages - guide users to fix validation errors
  5. Disable submit during processing - prevent duplicate submissions
  6. Reset forms after success - clear form state
  7. Watch for field dependencies - show/hide fields based on other values
  8. Controller for custom components - integrate third-party inputs
  9. Multi-step forms validate per step before advancing
  10. Field arrays for dynamic form fields (batch operations)

Next Steps: Review React Testing for testing forms and React State Management for form state patterns.