Skip to content

Frontend

React Hook Form + Zod — The Form Pattern That Just Works

All articles
⚛️ 🚀

Most form libraries are over-engineered. React Hook Form + Zod is the opposite: minimal API, maximum type safety. Pass a schema to a hook, wire up inputs, validation is free. No context, no context hell, no boilerplate. This is how production forms work.

Form libraries have a terrible reputation. Formik adds 30K to your bundle. Zustand-only forms leak internal state all over your tree. Vanilla controlled components are tedious. The reason: most form libraries try to solve three problems at once — state management, validation, and UI binding — and they do all three badly. React Hook Form + Zod solves this by splitting the problems clean: Hook Form owns state and submission, Zod owns validation rules, and you own the UI. Each layer is replaceable. Swap shadcn inputs for vanilla divs, swap Zod for a custom validator, swap Hook Form for... well, you won't, because Hook Form is 8.8K and it doesn't fight the DOM. This is the form stack that powers Aidxn's production apps, and it scales from a contact form to a multi-step dashboard wizard without a single line of extra setup.

The Problem: Form Libraries Overreach

Formik is the industry standard, and Formik is a mistake. It bundles validation rules, error handling, field state, and dirty-tracking into a single giant object that you pass down through context. Modify one field, and Formik rerenders every field. Build a nested form (questions inside a survey inside a page), and you're managing three separate Formik instances with their own state trees, fighting each other for the same input. Add async validation (checking if an email is registered), and you're writing custom middleware. The API is flexible, but flexibility costs: the mental model is too large, and every team ends up with a different interpretation of "the Formik way."

React Hook Form flips this. It doesn't own your state — it borrows from the DOM. When you type into an input, the DOM updates immediately (you feel the keystroke), and Hook Form watches for changes and runs validation in the background. No context, no store, no intermediary. The form is always responsive. You submit, Hook Form validates (using your Zod schema), and calls your submit handler with typed, validated data. That's it. Zod provides the schema — define once, use everywhere.

The result: 30 lines of code. Fully typed. Zero boilerplate. Works with any UI library (Tailwind, shadcn, vanilla HTML). Swaps out cleanly. This is why it's become the standard in the React ecosystem.

The Pattern: 30 Lines of Form Code

Here's a complete, production-ready form using React Hook Form + Zod:

// src/components/SignupForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

const signupSchema = z.object({
  name: z.string().min(2, 'Name must be 2+ chars'),
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be 8+ chars'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords don\'t match',
  path: ['confirmPassword'],
});

type SignupForm = z.infer<typeof signupSchema>;

export function SignupForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
  });

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

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <Input {...register('name')} placeholder="Your name" />
        {errors.name && <span className="text-red-500 text-sm">{errors.name.message}</span>}
      </div>
      <div>
        <Input {...register('email')} type="email" placeholder="Email" />
        {errors.email && <span className="text-red-500 text-sm">{errors.email.message}</span>}
      </div>
      <div>
        <Input {...register('password')} type="password" placeholder="Password" />
        {errors.password && <span className="text-red-500 text-sm">{errors.password.message}</span>}
      </div>
      <div>
        <Input {...register('confirmPassword')} type="password" placeholder="Confirm" />
        {errors.confirmPassword && <span className="text-red-500 text-sm">{errors.confirmPassword.message}</span>}
      </div>
      <Button type="submit" disabled={isSubmitting}>Sign Up</Button>
    </form>
  );
}

That's it. The schema defines the shape (4 fields, all strings, password confirmation must match). React Hook Form runs Zod on blur/change, populates the `errors` object, and disables the button during submission. The type `SignupForm` is inferred directly from the schema — change the schema, and TypeScript screams at you if your code disagrees. No type duplication. No manual interfaces. This is how typing should feel.

Why This Stack Beats The Alternatives

vs. Formik: Formik is 37K minified (Hook Form is 8.8K). Formik manages state in a global context tree, so every field update triggers a re-render of the entire form. Hook Form uses uncontrolled components, so your inputs update instantly and validation happens in the background. Formik's API is large (formik.touched, formik.isValidating, formik.dirty). Hook Form's API is tiny (register, watch, handleSubmit). For simple forms, Formik works. For complex forms, Hook Form scales cleanly.

vs. Zustand-only: If you manage form state purely in Zustand, you're adding 15 lines of store boilerplate to every form. You have to wire up field changes manually, handle submission, add validation somehow. It works, but you're not reusing patterns — every form is custom. Hook Form + Zod is a pattern. Copy the shape, wire the schema, done.

vs. React Final Form: Final Form is lighter than Formik (5.7K) but it's still a subscription-based state manager. Hook Form is simpler: uncontrolled by default, validation on demand. If your form is a dropdown and a text field, Final Form is fine. If your form is a dashboard with 20 fields, conditional sections, and async validators, Hook Form's simplicity scales better.

Four Advanced Patterns

Pattern 1: Conditional Fields

Show/hide fields based on the value of another field. Use `watch` to subscribe to a field, and conditionally render.

const { register, watch, formState: { errors } } = useForm<SignupForm>({
  resolver: zodResolver(signupSchema),
});

const accountType = watch('accountType');

return (
  <>
    <select {...register('accountType')}>
      <option>Personal</option>
      <option>Business</option>
    </select>
    {accountType === 'business' && (
      <Input {...register('businessName')} placeholder="Company name" />
    )}
  </>
);

Pattern 2: Async Validation

Check if an email is already registered by querying the API during validation.

const signupSchema = z.object({
  email: z.string().email(),
}).refine(
  async (data) => {
    const res = await fetch(`/api/check-email?email=${data.email}`);
    const { available } = await res.json();
    return available;
  },
  { message: 'Email already registered', path: ['email'] }
);

Hook Form supports async refinements. The submit button is disabled while validation is in-flight, and the error appears once the check completes.

Pattern 3: File Upload with Preview

Handle file uploads by casting the input to a File, then reading the blob.

const schema = z.object({
  avatar: z.instanceof(FileList).refine((files) => files.length > 0, 'File required'),
});

export function AvatarForm() {
  const { register, handleSubmit } = useForm({ resolver: zodResolver(schema) });

  const onSubmit = (data) => {
    const file = data.avatar[0];
    const reader = new FileReader();
    reader.onload = (e) => console.log(e.target?.result);
    reader.readAsDataURL(file);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="file" {...register('avatar')} />
      <button type="submit">Upload</button>
    </form>
  );
}

Pattern 4: Multi-Step Forms

Split a large schema into steps, validate each step independently, then submit all together.

const step1Schema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

const step2Schema = z.object({
  password: z.string().min(8),
  terms: z.boolean(),
});

const fullSchema = step1Schema.merge(step2Schema);

export function MultiStepForm() {
  const { register, handleSubmit, trigger, formState: { errors } } = useForm({
    resolver: zodResolver(fullSchema),
  });

  const [step, setStep] = useState(1);

  const nextStep = async () => {
    const valid = await trigger(step === 1 ? ['name', 'email'] : ['password', 'terms']);
    if (valid) setStep(step + 1);
  };

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

  return (
    <>
      {step === 1 && (
        <>
          <Input {...register('name')} />
          <Input {...register('email')} />
          <button onClick={nextStep}>Next</button>
        </>
      )}
      {step === 2 && (
        <form onSubmit={handleSubmit(onSubmit)}>
          <Input {...register('password')} type="password" />
          <label><input {...register('terms')} type="checkbox" /> I agree</label>
          <button type="submit">Complete</button>
        </form>
      )}
    </>
  );
}

Six FAQs

Should I use controlled or uncontrolled components?

Uncontrolled by default with Hook Form's `register`. If you need to programmatically set a value (e.g., populate a form with fetched data), use `reset` to set all fields at once or `Controller` to wrap a controlled component. Avoid mixing — pick one strategy per form.

How do I set initial values?

Pass `defaultValues` to `useForm`: `useForm({ defaultValues: { name: 'John', email: 'john@ex.com' } })`. If you're fetching data async, use `reset(data)` once the fetch completes.

Can I validate on every keystroke or just on blur?

Hook Form validates on blur by default. Set `mode: 'onChange'` in `useForm` to validate on every keystroke, or `mode: 'onBlur'` for submission time only. `onChange` is better for UX (instant feedback), but use it wisely — avoid expensive async validators on every keystroke.

How do I show a loading state during submission?

Use `formState: { isSubmitting }` from `useForm`. Disable the submit button with `disabled={isSubmitting}` and show a spinner.

What if I need to submit the form from outside the form component?

Export the form as an uncontrolled component and accept a ref. Hook Form's `useFormContext` also lets you extract the form from any child component — useful for split layouts.

How do I integrate Hook Form with a UI library like shadcn?

shadcn Form component wraps Hook Form and provides boilerplate for error messages and labels. Use it if you want pre-styled form fields, or wire Hook Form directly into any input component. shadcn saves time, but it's optional — Hook Form works standalone.

The Bottom Line

React Hook Form + Zod is the form pattern that scales. Start with a contact form, add conditional fields, async validation, file uploads, and multi-step flows — the same 30-line pattern handles all of it. No context hell, no giant state objects, no prop drilling. Zod defines your data shape once; Hook Form orchestrates validation and submission. When you're ready to ship forms at scale, this is the stack that won't fight you. Ready to ship full-stack apps with type-safe forms? Check Aidxn Design pricing for production partnerships. For more on data validation at scale, read Zod Schemas as Single Source of Truth — One File, Three Validations.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.