Back to Blog
Building a Reusable Form System with React Hook Form + Zod and TypeScript

Building a Reusable Form System with React Hook Form + Zod and TypeScript

Learn to create a powerful, type-safe form system using React Hook Form, Zod validation, and TypeScript. Build reusable components that handle validation, errors, and submissions elegantly.

Naim HasanNaim Hasan
09-20-2025
8 min

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 (
    
{/* Name Fields */}
{/* Email */} {/* Password Fields */}
{/* Age */} {/* Country */} {/* Terms Checkbox */}
{errors.terms && (

{errors.terms.message}

)}
{/* Submit Button */} ); }; export default UserRegistrationForm;

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.

#React#TypeScript#React Hook Form#Zod#Forms#Validation#Best Practices