, and accessibility attributes across the application.
2. Implementation
Step 1: Define the Schema and Types
Create a Zod schema that mirrors the API contract. Infer types to ensure compile-time safety.
// schemas/userProfile.schema.ts
import { z } from 'zod';
export const userProfileSchema = z.object({
username: z.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username cannot exceed 20 characters'),
email: z.string()
.email('Invalid email format')
.toLowerCase(),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
bio: z.string().optional(),
});
export type UserProfileFormValues = z.infer<typeof userProfileSchema>;
Step 2: Create the Form Wrapper
Initialize the form with the resolver. Configure validation modes to optimize performance.
// components/UserProfileForm.tsx
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userProfileSchema, UserProfileFormValues } from './schemas/userProfile.schema';
import { FormField } from './FormField';
import { Button } from './Button';
interface UserProfileFormProps {
defaultValues?: Partial<UserProfileFormValues>;
onSubmit: (data: UserProfileFormValues) => Promise<void>;
}
export function UserProfileForm({ defaultValues, onSubmit }: UserProfileFormProps) {
const methods = useForm<UserProfileFormValues>({
resolver: zodResolver(userProfileSchema),
defaultValues: {
username: '',
email: '',
role: 'viewer',
...defaultValues,
},
mode: 'onTouched', // Validates only after field interaction
reValidateMode: 'onChange',
});
const handleSubmit = methods.handleSubmit(async (data) => {
try {
await onSubmit(data);
} catch (error) {
// Handle API errors, map to form fields if necessary
console.error('Submission failed', error);
}
});
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit} noValidate>
<div className="form-grid">
<FormField name="username" label="Username" />
<FormField name="email" label="Email" />
<FormField name="role" label="Role" as="select" />
<FormField name="bio" label="Bio" as="textarea" />
</div>
<Button type="submit" disabled={methods.formState.isSubmitting}>
{methods.formState.isSubmitting ? 'Saving...' : 'Save Profile'}
</Button>
</form>
</FormProvider>
);
}
Step 3: Build the Reusable FormField Component
This abstraction handles the Controller logic, error display, and accessibility attributes.
// components/FormField.tsx
import { useController, UseControllerProps, FieldValues, Path } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
interface FormFieldProps<T extends FieldValues> extends UseControllerProps<T> {
label: string;
as?: 'input' | 'select' | 'textarea';
placeholder?: string;
options?: { value: string; label: string }[];
}
export function FormField<T extends FieldValues>({
name,
label,
as = 'input',
placeholder,
options,
...rest
}: FormFieldProps<T>) {
const { control, formState: { errors } } = useFormContext<T>();
const {
field,
fieldState: { error, invalid },
} = useController({ name, control, ...rest });
const errorMessage = error?.message;
const inputId = `field-${name}`;
const errorId = `error-${name}`;
const renderInput = () => {
switch (as) {
case 'select':
return (
<select
id={inputId}
{...field}
aria-invalid={invalid}
aria-describedby={invalid ? errorId : undefined}
>
<option value="">Select role...</option>
{options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case 'textarea':
return (
<textarea
id={inputId}
{...field}
placeholder={placeholder}
aria-invalid={invalid}
aria-describedby={invalid ? errorId : undefined}
/>
);
default:
return (
<input
id={inputId}
type="text"
{...field}
placeholder={placeholder}
aria-invalid={invalid}
aria-describedby={invalid ? errorId : undefined}
/>
);
}
};
return (
<div className="form-field">
<label htmlFor={inputId}>{label}</label>
{renderInput()}
{errorMessage && (
<p id={errorId} className="error-message" role="alert">
{errorMessage}
</p>
)}
</div>
);
}
3. Advanced Pattern: Async Validation
For fields requiring server-side checks (e.g., username availability), extend the schema with superRefine or use the form's trigger method with debounce.
// Example: Debounced async validation in schema
const schemaWithAsync = z.object({
username: z.string().refine(async (val) => {
// Simulate API call
const isTaken = await checkUsernameAvailability(val);
return !isTaken;
}, { message: 'Username is already taken' }),
});
Pitfall Guide
-
State Duplication and Source of Truth Drift
- Mistake: Maintaining form state in both React state and the form library, or duplicating validation logic between Zod schemas and manual checks.
- Impact: Bugs caused by desynchronized state; maintenance overhead.
- Best Practice: The Zod schema is the single source of truth. Derive all types and validation from it. Never store form data in local component state unless it is ephemeral UI state (e.g., loading spinner for a specific field).
-
Overusing watch
- Mistake: Calling
watch() without arguments or on high-frequency fields to derive UI changes.
- Impact: Causes re-renders on every input change, negating the performance benefits of uncontrolled inputs.
- Best Practice: Use
watch('specificField') only when necessary. Prefer useWatch for isolated subscriptions. For derived values, calculate them in the onSubmit handler or use a useMemo based on the specific watched fields.
-
Ignoring reset vs setValue Nuances
- Mistake: Using
setValue to update the entire form when data changes, or failing to call reset when navigating between form instances.
- Impact: Stale data persists; validation state becomes inconsistent.
- Best Practice: Use
reset when loading new data or clearing the form. Use setValue for granular updates. Ensure resetValues are updated in useEffect when defaultValues change from props.
-
Accessibility Regressions in Custom Controls
- Mistake: Wrapping inputs in custom components without forwarding
ref, id, aria-invalid, or aria-describedby.
- Impact: Screen readers cannot associate labels with inputs; error messages are not announced.
- Best Practice: The
FormField abstraction must enforce htmlFor on labels and aria-describedby on inputs pointing to the error element. Always forward refs to the underlying DOM element.
-
Hydration Mismatches in SSR
- Mistake: Omitting
defaultValues or providing dynamic values that differ between server and client render.
- Impact: React hydration errors; UI flicker.
- Best Practice: Ensure
defaultValues are provided statically or derived from data available during SSR. If values depend on client-only logic, use useEffect to set values after mount, or accept the hydration mismatch for non-critical attributes.
-
Schema Drift from API Contracts
- Mistake: Modifying the backend API without updating the Zod schema, or vice versa.
- Impact: Runtime validation failures; data loss.
- Best Practice: Generate Zod schemas from OpenAPI/Swagger specs using tools like
openapi-zod-client where possible. Establish a contract-first workflow.
-
Missing mode Configuration
- Mistake: Relying on default validation modes which may trigger validation too aggressively or too late.
- Impact: Poor UX with immediate error messages on focus, or delayed feedback until submit.
- Best Practice: Configure
mode: 'onTouched' for standard forms to validate after interaction. Use reValidateMode: 'onChange' to update errors as the user corrects input. Adjust based on specific UX requirements.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Static Form | Native HTML + Zod Validation | Minimal overhead; no library needed for basic submission. | Low (0 KB) |
| Complex Wizard/Multi-step | RHF + Schema Array | RHF handles isolated step state efficiently; schemas validate steps independently. | Medium (~14 KB) |
| High-Frequency Grid Editing | Uncontrolled Refs + Custom State | RHF overhead may be too high for 100+ simultaneous inputs; refs offer maximum performance. | Low (0 KB) |
| Enterprise App with Design System | RHF + Zod + FormField Abstraction | Ensures consistency, accessibility, and type safety across teams. | Medium (~14 KB + Abstraction) |
| SSR-Heavy Application | RHF + Zod + Strict Defaults | RHF supports SSR well if defaults are managed; prevents hydration errors. | Medium (~14 KB) |
Configuration Template
Copy this template to initialize a standardized form setup in a new project.
// lib/form.ts
import { FieldValues, UseFormProps, useForm } from 'react-hook-form';
import { ZodSchema, ZodTypeDef } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// Generic form hook with strict typing
export function useTypedForm<TSchema extends ZodSchema>(
schema: TSchema,
props?: Omit<UseFormProps, 'resolver'>
) {
return useForm({
resolver: zodResolver(schema),
mode: 'onTouched',
reValidateMode: 'onChange',
...props,
});
}
// Usage:
// const form = useTypedForm(userSchema, { defaultValues: { ... } });
Quick Start Guide
- Install Dependencies:
npm install react-hook-form @hookform/resolvers zod
- Create Schema:
// schema.ts
import { z } from 'zod';
export const mySchema = z.object({ name: z.string().min(1) });
export type MyForm = z.infer<typeof mySchema>;
- Initialize Form:
// MyForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { mySchema, MyForm } from './schema';
export function MyForm() {
const { register, handleSubmit, formState: { errors } } = useForm<MyForm>({
resolver: zodResolver(mySchema),
});
// ... render form
}
- Run and Verify:
Execute
npm run dev. Interact with the form to verify validation triggers on blur and submission is type-safe. Check bundle size impact using your build analyzer; the library should add approximately 14KB gzipped.