TrueSpec

React Form Validation (React Hook Form + Zod)

Type-safe forms, custom validators, error display, and submission handling

What You’ll Build

After following this guide, you will have a working implementation of react form validation (react hook form + zod) in your project. Build type-safe, performant forms in React using React Hook Form + Zod. React Hook Form uses uncontrolled inputs for minimal re-renders, and Zod provides schema validation with automatic TypeScript type inference. No boilerplate, no performance issues.

Use Cases & Problems Solved

  • Implement interactive UI features that users expect from modern apps
  • Follow established patterns that scale and remain maintainable
  • Reduce boilerplate and avoid common frontend pitfalls

Prerequisites

  • React 18+
  • TypeScript (recommended)

Step-by-Step Implementation

Install dependencies

The following snippet shows how to install dependencies. Copy this into your project and adjust the values for your environment.

npm install react-hook-form zod @hookform/resolvers

Define schema and form

The following snippet shows how to define schema and form. Copy this into your project and adjust the values for your environment.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Must be at least 8 characters'),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

type FormData = z.infer<typeof schema>;

export default function SignUpForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data: FormData) => {
    await fetch('/api/signup', { method: 'POST', body: JSON.stringify(data) });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} placeholder="Email" />
      {errors.email && <span className="error">{errors.email.message}</span>}

      <input {...register('password')} type="password" placeholder="Password" />
      {errors.password && <span className="error">{errors.password.message}</span>}

      <input {...register('confirmPassword')} type="password" placeholder="Confirm" />
      {errors.confirmPassword && <span className="error">{errors.confirmPassword.message}</span>}

      <button disabled={isSubmitting}>{isSubmitting ? 'Signing up...' : 'Sign Up'}</button>
    </form>
  );
}

⚠️ Don’t Do This

❌ Using controlled inputs with useState for every field

// Re-renders entire form on EVERY keystroke!
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
<input value={email} onChange={e => setEmail(e.target.value)} />

✅ Use uncontrolled inputs with register (React Hook Form)

// Only re-renders on submit or validation — much faster!
const { register } = useForm();
<input {...register('email')} /> // No useState needed

Testing

Verify your implementation with these tests:

// __tests__/react-form-validation-react-hook-form-zod-.test.ts
import { describe, it, expect } from 'vitest';

describe('React Form Validation (React Hook Form + Zod)', () => {
  it('should initialize without errors', () => {
    // Test that the setup completes successfully
    expect(() => setup()).not.toThrow();
  });

  it('should handle the primary use case', async () => {
    const result = await execute();
    expect(result).toBeDefined();
    expect(result.success).toBe(true);
  });

  it('should handle edge cases', async () => {
    // Test with empty/null input
    const result = await execute(null);
    expect(result.error).toBeDefined();
  });
});

Verification

npm start
# Fill form with invalid email → error shows inline
# Type mismatching passwords → 'do not match' error
# Submit valid form → network tab shows POST request
# Check isSubmitting disables button during submission

Related Specs

Intermediate

Next.js Server Actions Guide

Form mutations, revalidation, optimistic updates, and error handling

Frontend Patterns
Beginner

Zustand State Management

Store setup, slices, persist middleware, devtools, and TypeScript

Frontend Patterns
Beginner

Toast Notification System

Custom toast component, queue management, animations, and accessibility

Frontend Patterns