DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Zod + React Hook Form: Type-Safe Forms Without the Pain

Forms Are Harder Than They Look

Validation logic, error messages, loading states, server errors—form handling adds up fast. React Hook Form + Zod eliminates most of the boilerplate.

Setup

npm install react-hook-form @hookform/resolvers zod
Enter fullscreen mode Exit fullscreen mode

Basic Pattern

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

// 1. Define schema
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

// 2. Infer type from schema
type LoginForm = z.infer<typeof loginSchema>;

// 3. Build form
function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginForm) => {
    try {
      await signIn(data.email, data.password);
    } catch (error) {
      // Set server-side error on specific field
      setError('email', { message: 'Invalid credentials' });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          {...register('email')}
          type="email"
          placeholder="Email"
        />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

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

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Complex Validation

const signupSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email(),
  password: z.string()
    .min(8)
    .regex(/[A-Z]/, 'Must contain uppercase letter')
    .regex(/[0-9]/, 'Must contain number'),
  confirmPassword: z.string(),
  plan: z.enum(['free', 'pro', 'enterprise']),
  acceptTerms: z.boolean().refine(val => val === true, {
    message: 'You must accept the terms',
  }),
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});
Enter fullscreen mode Exit fullscreen mode

With shadcn/ui Components

import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

function SignupForm() {
  const form = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
    defaultValues: { plan: 'free', acceptTerms: false },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage /> {/* auto-displays error */}
            </FormItem>
          )}
        />

        <Button type="submit" disabled={form.formState.isSubmitting}>
          Create account
        </Button>
      </form>
    </Form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Server Action Integration (Next.js)

'use server';
import { loginSchema } from '@/lib/schemas';

export async function loginAction(formData: FormData) {
  const rawData = Object.fromEntries(formData);

  const result = loginSchema.safeParse(rawData);
  if (!result.success) {
    return { error: result.error.flatten().fieldErrors };
  }

  const { email, password } = result.data;
  // ... authenticate
}

// Client component
'use client';
function LoginForm() {
  const [serverErrors, setServerErrors] = useState<Record<string, string[]>>({});

  const onSubmit = async (data: LoginForm) => {
    const formData = new FormData();
    Object.entries(data).forEach(([k, v]) => formData.append(k, String(v)));

    const result = await loginAction(formData);
    if (result?.error) {
      setServerErrors(result.error);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Reusable Form Components

// Generic field component
function FormInput<T extends FieldValues>({
  form,
  name,
  label,
  type = 'text',
}: {
  form: UseFormReturn<T>;
  name: Path<T>;
  label: string;
  type?: string;
}) {
  return (
    <FormField
      control={form.control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <Input type={type} {...field} />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

// Usage
<FormInput form={form} name="email" label="Email" type="email" />
<FormInput form={form} name="password" label="Password" type="password" />
Enter fullscreen mode Exit fullscreen mode

The Key Benefits

  1. Single source of truth: Zod schema drives both runtime validation and TypeScript types
  2. No manual error state: React Hook Form manages all field state
  3. Performance: Only re-renders affected fields, not the entire form
  4. Server/client parity: Same Zod schema validates on both sides

The pattern scales from a 2-field login form to a 20-field onboarding wizard without changing your approach.


Forms with Zod validation, server actions, and shadcn/ui components: Whoff Agents AI SaaS Starter Kit.

Top comments (0)