Form Patterns
Reusable patterns for building forms in React applications.
Form Component Structure
// components/forms/contact-form.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactForm } from '@/schemas/contact';
interface ContactFormProps {
onSuccess?: () => void;
defaultValues?: Partial<ContactForm>;
}
export function ContactForm({ onSuccess, defaultValues }: ContactFormProps) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setError,
} = useForm<ContactForm>({
resolver: zodResolver(contactSchema),
defaultValues,
});
const onSubmit = async (data: ContactForm) => {
try {
await submitContactForm(data);
reset();
onSuccess?.();
} catch (error) {
setError('root', {
message: 'Failed to submit. Please try again.',
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{errors.root && (
<div className="error-banner">{errors.root.message}</div>
)}
{/* Form fields */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}Reusable Input Components
Text Input
// components/ui/form-input.tsx
import { forwardRef } from 'react';
import { type FieldError } from 'react-hook-form';
interface FormInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: FieldError;
}
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
({ label, error, id, ...props }, ref) => {
const inputId = id || props.name;
return (
<div className="form-field">
<label htmlFor={inputId}>{label}</label>
<input
ref={ref}
id={inputId}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
{...props}
/>
{error && (
<span id={`${inputId}-error`} className="error">
{error.message}
</span>
)}
</div>
);
}
);
FormInput.displayName = 'FormInput';
// Usage
<FormInput
label="Email"
type="email"
error={errors.email}
{...register('email')}
/>Select Input
// components/ui/form-select.tsx
import { forwardRef } from 'react';
import { type FieldError } from 'react-hook-form';
interface Option {
value: string;
label: string;
}
interface FormSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label: string;
options: Option[];
error?: FieldError;
placeholder?: string;
}
export const FormSelect = forwardRef<HTMLSelectElement, FormSelectProps>(
({ label, options, error, placeholder, id, ...props }, ref) => {
const selectId = id || props.name;
return (
<div className="form-field">
<label htmlFor={selectId}>{label}</label>
<select
ref={ref}
id={selectId}
aria-invalid={!!error}
{...props}
>
{placeholder && (
<option value="">{placeholder}</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && <span className="error">{error.message}</span>}
</div>
);
}
);
FormSelect.displayName = 'FormSelect';Checkbox
// components/ui/form-checkbox.tsx
import { forwardRef } from 'react';
import { type FieldError } from 'react-hook-form';
interface FormCheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: FieldError;
}
export const FormCheckbox = forwardRef<HTMLInputElement, FormCheckboxProps>(
({ label, error, id, ...props }, ref) => {
const inputId = id || props.name;
return (
<div className="form-field-checkbox">
<input ref={ref} type="checkbox" id={inputId} {...props} />
<label htmlFor={inputId}>{label}</label>
{error && <span className="error">{error.message}</span>}
</div>
);
}
);
FormCheckbox.displayName = 'FormCheckbox';Multi-Step Forms
// components/forms/multi-step-form.tsx
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const steps = [
{ id: 'personal', title: 'Personal Info', component: PersonalStep },
{ id: 'address', title: 'Address', component: AddressStep },
{ id: 'payment', title: 'Payment', component: PaymentStep },
];
export function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(0);
const methods = useForm<CheckoutForm>({
resolver: zodResolver(checkoutSchema),
mode: 'onChange',
});
const { trigger, handleSubmit } = methods;
const nextStep = async () => {
// Validate current step fields before proceeding
const fieldsToValidate = getStepFields(currentStep);
const isValid = await trigger(fieldsToValidate);
if (isValid) {
setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
}
};
const prevStep = () => {
setCurrentStep((prev) => Math.max(prev - 1, 0));
};
const onSubmit = async (data: CheckoutForm) => {
await processCheckout(data);
};
const CurrentStepComponent = steps[currentStep].component;
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Progress indicator */}
<div className="steps">
{steps.map((step, index) => (
<div
key={step.id}
className={index <= currentStep ? 'active' : ''}
>
{step.title}
</div>
))}
</div>
{/* Current step content */}
<CurrentStepComponent />
{/* Navigation */}
<div className="actions">
{currentStep > 0 && (
<button type="button" onClick={prevStep}>
Back
</button>
)}
{currentStep < steps.length - 1 ? (
<button type="button" onClick={nextStep}>
Next
</button>
) : (
<button type="submit">Complete</button>
)}
</div>
</form>
</FormProvider>
);
}
// Step component example
function PersonalStep() {
const { register, formState: { errors } } = useFormContext<CheckoutForm>();
return (
<div>
<FormInput label="Name" error={errors.name} {...register('name')} />
<FormInput label="Email" error={errors.email} {...register('email')} />
</div>
);
}Dynamic Form Fields
import { useFieldArray, useForm } from 'react-hook-form';
interface FormData {
users: { name: string; email: string }[];
}
function DynamicFieldsForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<FormData>({
defaultValues: {
users: [{ name: '', email: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'users',
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id} className="user-row">
<input
{...register(`users.${index}.name`)}
placeholder="Name"
/>
{errors.users?.[index]?.name && (
<span>{errors.users[index].name.message}</span>
)}
<input
{...register(`users.${index}.email`)}
placeholder="Email"
/>
{errors.users?.[index]?.email && (
<span>{errors.users[index].email.message}</span>
)}
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', email: '' })}>
Add User
</button>
<button type="submit">Submit</button>
</form>
);
}Dependent Fields
function DependentFieldsForm() {
const { register, watch, setValue } = useForm<FormData>();
const country = watch('country');
// Reset state when country changes
useEffect(() => {
setValue('state', '');
}, [country, setValue]);
const states = country ? getStatesForCountry(country) : [];
return (
<form>
<select {...register('country')}>
<option value="">Select country...</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
<select {...register('state')} disabled={!country}>
<option value="">Select state...</option>
{states.map((state) => (
<option key={state.code} value={state.code}>
{state.name}
</option>
))}
</select>
</form>
);
}Form with API Integration
import { useMutation } from '@tanstack/react-query';
function CreateUserForm() {
const {
register,
handleSubmit,
setError,
reset,
formState: { errors },
} = useForm<CreateUserForm>({
resolver: zodResolver(createUserSchema),
});
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
reset();
toast.success('User created!');
},
onError: (error: ApiError) => {
// Handle field-specific errors from API
if (error.errors) {
Object.entries(error.errors).forEach(([field, message]) => {
setError(field as keyof CreateUserForm, { message });
});
} else {
setError('root', { message: error.message });
}
},
});
return (
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
{errors.root && <div className="error">{errors.root.message}</div>}
<FormInput label="Name" error={errors.name} {...register('name')} />
<FormInput label="Email" error={errors.email} {...register('email')} />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}Debounced Validation
import { useCallback } from 'react';
import debounce from 'lodash/debounce';
function UsernameForm() {
const { register, setError, clearErrors } = useForm<FormData>();
const checkUsername = useCallback(
debounce(async (username: string) => {
if (username.length < 3) return;
const exists = await checkUsernameExists(username);
if (exists) {
setError('username', { message: 'Username already taken' });
} else {
clearErrors('username');
}
}, 500),
[setError, clearErrors]
);
return (
<form>
<input
{...register('username', {
onChange: (e) => checkUsername(e.target.value),
})}
/>
</form>
);
}Form Styling with Tailwind
function StyledForm() {
const { register, formState: { errors } } = useForm<FormData>();
return (
<form className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
{...register('email')}
className={`
mt-1 block w-full rounded-md shadow-sm
${errors.email
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
}
`}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<button
type="submit"
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white
hover:bg-blue-700 disabled:opacity-50"
>
Submit
</button>
</form>
);
}Best Practices
- Use noValidate - Disable browser validation, let React Hook Form handle it
- Show loading states - Disable submit button and show spinner
- Handle API errors - Map server errors to form fields
- Accessibility - Use labels, aria attributes, and error announcements
- Reset on success - Clear form after successful submission
- Optimistic updates - Show success state before API confirms