Forms are everywhere in web applications, yet they're often the most frustrating part to build and maintain. Today, I'll show you how to create a reusable form system that combines React Hook Form's performance, Zod's validation power, and TypeScript's type safety into one elegant solution.
Why This Combination Works
Traditional form handling in React involves a lot of boilerplate code, manual state management, and repetitive validation logic. Our approach solves these problems by:
- Using React Hook Form for optimal performance (minimal re-renders)
- Leveraging Zod for schema-first validation with TypeScript inference
- Creating reusable components that work consistently across your app
- Providing excellent developer experience with full type safety
Setting Up the Foundation
First, let's install the required dependencies and set up our base types:
npm install react-hook-form zod @hookform/resolvers
npm install -D @types/react
Now, let's create our base form types and utilities:
// types/form.ts
import { FieldError, UseFormRegister } from 'react-hook-form';
import { z } from 'zod';
export interface FormFieldProps {
name: string;
label?: string;
placeholder?: string;
error?: FieldError;
required?: boolean;
disabled?: boolean;
className?: string;
}
export interface InputFieldProps extends FormFieldProps {
type?: 'text' | 'email' | 'password' | 'number' | 'tel';
register: UseFormRegister;
}
export interface SelectOption {
value: string | number;
label: string;
disabled?: boolean;
}
export interface SelectFieldProps extends FormFieldProps {
options: SelectOption[];
register: UseFormRegister;
}
Creating Reusable Input Components
Let's build our core input components with consistent styling and error handling:
Base Input Field
// components/form/InputField.tsx
import React from 'react';
import { InputFieldProps } from '../../types/form';
import { cn } from '../../lib/utils';
const InputField: React.FC = ({
name,
label,
placeholder,
type = 'text',
error,
required = false,
disabled = false,
className,
register,
}) => {
return (
{label && (
)}
{error && (
{error.message}
)}
);
};
export default InputField;
Select Field Component
// components/form/SelectField.tsx
import React from 'react';
import { SelectFieldProps } from '../../types/form';
import { cn } from '../../lib/utils';
const SelectField: React.FC = ({
name,
label,
placeholder,
options,
error,
required = false,
disabled = false,
className,
register,
}) => {
return (
{label && (
)}
{error && (
{error.message}
)}
);
};
export default SelectField;
Defining Zod Schemas
Now let's create type-safe validation schemas using Zod. Notice how we get automatic TypeScript types:
// schemas/userSchema.ts
import { z } from 'zod';
// User registration schema
export const userRegistrationSchema = z.object({
firstName: z
.string()
.min(2, 'First name must be at least 2 characters')
.max(50, 'First name cannot exceed 50 characters'),
lastName: z
.string()
.min(2, 'Last name must be at least 2 characters')
.max(50, 'Last name cannot exceed 50 characters'),
email: z
.string()
.email('Please enter a valid email address')
.toLowerCase(),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/(?=.*[a-z])/, 'Password must contain at least one lowercase letter')
.regex(/(?=.*[A-Z])/, 'Password must contain at least one uppercase letter')
.regex(/(?=.*\d)/, 'Password must contain at least one number')
.regex(/(?=.*[@$!%*?&])/, 'Password must contain at least one special character'),
confirmPassword: z.string(),
age: z
.number({
required_error: 'Age is required',
invalid_type_error: 'Age must be a number',
})
.min(13, 'You must be at least 13 years old')
.max(120, 'Please enter a valid age'),
country: z
.string()
.min(1, 'Please select your country'),
terms: z
.boolean()
.refine((val) => val === true, {
message: 'You must agree to the terms and conditions',
}),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// Contact form schema
export const contactFormSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name cannot exceed 100 characters'),
email: z
.string()
.email('Please enter a valid email address'),
subject: z
.string()
.min(5, 'Subject must be at least 5 characters')
.max(200, 'Subject cannot exceed 200 characters'),
message: z
.string()
.min(10, 'Message must be at least 10 characters')
.max(1000, 'Message cannot exceed 1000 characters'),
priority: z.enum(['low', 'medium', 'high'], {
required_error: 'Please select a priority level',
}),
});
// Automatically infer TypeScript types
export type UserRegistrationData = z.infer;
export type ContactFormData = z.infer;
Building the Complete Form Component
Now let's put everything together in a complete form example:
// components/UserRegistrationForm.tsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userRegistrationSchema, UserRegistrationData } from '../schemas/userSchema';
import InputField from './form/InputField';
import SelectField from './form/SelectField';
import { cn } from '../lib/utils';
interface UserRegistrationFormProps {
onSubmit: (data: UserRegistrationData) => Promise;
loading?: boolean;
className?: string;
}
const UserRegistrationForm: React.FC = ({
onSubmit,
loading = false,
className,
}) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm({
resolver: zodResolver(userRegistrationSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
country: '',
terms: false,
},
});
const countryOptions = [
{ value: 'us', label: 'United States' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'ca', label: 'Canada' },
{ value: 'au', label: 'Australia' },
{ value: 'de', label: 'Germany' },
{ value: 'bd', label: 'Bangladesh' },
];
const handleFormSubmit = async (data: UserRegistrationData) => {
try {
await onSubmit(data);
reset(); // Clear form on successful submission
} catch (error) {
console.error('Form submission error:', error);
}
};
return (
Using the Form in Your App
Here's how you'd use the form component in your application:
// pages/RegisterPage.tsx
import React, { useState } from 'react';
import UserRegistrationForm from '../components/UserRegistrationForm';
import { UserRegistrationData } from '../schemas/userSchema';
const RegisterPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const handleRegistration = async (data: UserRegistrationData) => {
setLoading(true);
try {
// Simulate API call
console.log('Registration data:', data);
const response = await fetch('/api/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Registration failed');
}
const result = await response.json();
console.log('Registration successful:', result);
// Handle success (e.g., redirect, show success message)
alert('Registration successful!');
} catch (error) {
console.error('Registration error:', error);
alert('Registration failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
Create Your Account
Join us today and get started with your journey
);
};
export default RegisterPage;
Advanced Features and Tips
1. Custom Validation Hooks
Create reusable validation hooks for complex scenarios:
// hooks/useAsyncValidation.ts
import { useCallback } from 'react';
import { z } from 'zod';
export const useAsyncValidation = () => {
const validateEmailUnique = useCallback(async (email: string) => {
// Simulate API call to check email uniqueness
const response = await fetch(`/api/check-email?email=${email}`);
const { isUnique } = await response.json();
if (!isUnique) {
throw new Error('This email is already registered');
}
return true;
}, []);
return { validateEmailUnique };
};
2. Form State Persistence
Save form data to localStorage to prevent data loss:
// hooks/useFormPersistence.ts
import { useEffect } from 'react';
import { UseFormWatch, UseFormSetValue } from 'react-hook-form';
export const useFormPersistence = (
formKey: string,
watch: UseFormWatch,
setValue: UseFormSetValue
) => {
const watchedValues = watch();
// Save to localStorage when form values change
useEffect(() => {
const subscription = watch((data) => {
localStorage.setItem(formKey, JSON.stringify(data));
});
return () => subscription.unsubscribe();
}, [watch, formKey]);
// Load from localStorage on mount
useEffect(() => {
const savedData = localStorage.getItem(formKey);
if (savedData) {
const parsedData = JSON.parse(savedData);
Object.entries(parsedData).forEach(([key, value]) => {
setValue(key as keyof T, value as any);
});
}
}, [formKey, setValue]);
const clearPersistedData = () => {
localStorage.removeItem(formKey);
};
return { clearPersistedData };
};
3. Dynamic Field Rendering
For even more reusability, create a dynamic form renderer:
// components/form/DynamicForm.tsx
import React from 'react';
import { UseFormRegister, FieldErrors } from 'react-hook-form';
import InputField from './InputField';
import SelectField from './SelectField';
import { SelectOption } from '../../types/form';
export interface FormFieldConfig {
name: string;
type: 'text' | 'email' | 'password' | 'number' | 'select';
label: string;
placeholder?: string;
required?: boolean;
options?: SelectOption[]; // For select fields
gridCol?: 'full' | 'half'; // For responsive layout
}
interface DynamicFormProps {
fields: FormFieldConfig[];
register: UseFormRegister;
errors: FieldErrors;
}
const DynamicForm: React.FC = ({ fields, register, errors }) => {
return (
{fields.map((field) => {
const commonProps = {
key: field.name,
name: field.name,
label: field.label,
placeholder: field.placeholder,
required: field.required,
error: errors[field.name],
register,
className: field.gridCol === 'full' ? 'md:col-span-2' : '',
};
if (field.type === 'select' && field.options) {
return (
);
}
return (
);
})}
);
};
export default DynamicForm;
Performance Optimizations
1. Debounced Validation
For expensive validations, use debouncing:
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
mode: 'onChange', // Validate on change
reValidateMode: 'onChange',
delayError: 500, // Debounce error display
});
2. Field-Level Validation
Split large schemas for better performance:
// Validate individual fields instead of entire form
const emailSchema = userSchema.pick({ email: true });
const passwordSchema = userSchema.pick({ password: true });
Wrapping Up
This form system gives you:
- ✅ Full Type Safety: Automatic TypeScript inference from Zod schemas
- ✅ Excellent Performance: Minimal re-renders with React Hook Form
- ✅ Reusable Components: Build once, use everywhere
- ✅ Rich Validation: Complex validation rules with great error messages
- ✅ Developer Experience: Autocomplete, error catching at compile time
- ✅ Accessibility: Proper labels, ARIA attributes, and error associations
The combination of React Hook Form, Zod, and TypeScript creates a powerful development experience. You write your validation rules once in a schema, and get automatic form validation, TypeScript types, and runtime safety. Your components become truly reusable, and maintaining forms across your application becomes much easier.
Start with simple forms like contact or login forms, then gradually build your component library. This approach scales beautifully from small projects to enterprise applications, and your future self will thank you for the consistency and maintainability this system provides.
