Back to skills
extension
Category: Development & EngineeringNo API key required

react-hook-form

React Hook Form with Zod validation. Covers form setup, validation schemas, field components, error handling, and submission with shadcn/ui integration. USE WHEN: user mentions "React Hook Form", "RHF", "Zod validation", "shadcn/ui forms", "zodResolver", "FormField", asks about "form validation with Zod", "shadcn form components" DO NOT USE FOR: React 19 Server Actions - use `react-19` skill instead, basic form handling - use `react-forms` skill instead, controlled/uncontrolled patterns - use `react-forms` skill instead

personAuthor: jakexiaohubgithub

React Hook Form with Zod

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: react-hook-form for comprehensive documentation on React Hook Form integration with Zod and shadcn/ui.

Form Setup

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

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

type LoginFormData = z.infer<typeof loginSchema>;

function LoginForm() {
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });

  const onSubmit = async (data: LoginFormData) => {
    await login(data);
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* fields */}
    </form>
  );
}

With shadcn/ui Form Components

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

function LoginForm() {
  const form = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: '', password: '' },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="email@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? 'Loading...' : 'Login'}
        </Button>
      </form>
    </Form>
  );
}

Complex Validation Schema

const userSchema = z.object({
  name: z.string()
    .min(2, 'Name must be at least 2 characters')
    .max(100, 'Name must be less than 100 characters'),
  email: z.string().email('Invalid email'),
  role: z.enum(['admin', 'user', 'manager'], {
    errorMap: () => ({ message: 'Select a valid role' }),
  }),
  department: z.string().optional(),
  startDate: z.date({
    required_error: 'Start date is required',
  }),
  salary: z.number()
    .min(0, 'Salary must be positive')
    .optional(),
});

// Conditional validation
const formSchema = z.object({
  hasAddress: z.boolean(),
  address: z.string().optional(),
}).refine(
  (data) => !data.hasAddress || data.address,
  { message: 'Address is required', path: ['address'] }
);

Select Field

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';

<FormField
  control={form.control}
  name="role"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Role</FormLabel>
      <Select onValueChange={field.onChange} defaultValue={field.value}>
        <FormControl>
          <SelectTrigger>
            <SelectValue placeholder="Select role" />
          </SelectTrigger>
        </FormControl>
        <SelectContent>
          <SelectItem value="admin">Admin</SelectItem>
          <SelectItem value="user">User</SelectItem>
          <SelectItem value="manager">Manager</SelectItem>
        </SelectContent>
      </Select>
      <FormMessage />
    </FormItem>
  )}
/>

Date Picker Field

import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { format } from 'date-fns';
import { CalendarIcon } from 'lucide-react';

<FormField
  control={form.control}
  name="startDate"
  render={({ field }) => (
    <FormItem className="flex flex-col">
      <FormLabel>Start Date</FormLabel>
      <Popover>
        <PopoverTrigger asChild>
          <FormControl>
            <Button variant="outline" className="w-full justify-start text-left">
              <CalendarIcon className="mr-2 h-4 w-4" />
              {field.value ? format(field.value, 'PPP') : 'Pick a date'}
            </Button>
          </FormControl>
        </PopoverTrigger>
        <PopoverContent className="w-auto p-0">
          <Calendar
            mode="single"
            selected={field.value}
            onSelect={field.onChange}
            initialFocus
          />
        </PopoverContent>
      </Popover>
      <FormMessage />
    </FormItem>
  )}
/>

Form with API Mutation

import { useMutation, useQueryClient } from '@tanstack/react-query';

function UserForm({ userId }: { userId?: string }) {
  const queryClient = useQueryClient();
  const isEditing = !!userId;

  const mutation = useMutation({
    mutationFn: isEditing ? usersApi.update : usersApi.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
      toast.success(isEditing ? 'User updated' : 'User created');
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });

  const onSubmit = (data: UserFormData) => {
    if (isEditing) {
      mutation.mutate({ id: userId, ...data });
    } else {
      mutation.mutate(data);
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        {/* fields */}
        <Button type="submit" disabled={mutation.isPending}>
          {mutation.isPending ? 'Saving...' : 'Save'}
        </Button>
      </form>
    </Form>
  );
}

Key Patterns

| Pattern | Description | |---------|-------------| | zodResolver | Zod validation integration | | form.formState | Form state (isSubmitting, errors, etc.) | | form.reset() | Reset form to defaults | | form.setValue() | Programmatic value setting | | form.watch() | Watch field changes | | form.trigger() | Trigger validation |

When NOT to Use This Skill

  • React 19 Server Actions forms - Use react-19 skill for useActionState patterns
  • Basic form handling - Use react-forms skill for general form patterns
  • Non-shadcn/ui components - Adapt patterns or use react-forms skill
  • Non-Zod validation - Use react-forms skill for other validation libraries

Anti-Patterns

| Anti-Pattern | Problem | Solution | |--------------|---------|----------| | Not using zodResolver | Manual validation, boilerplate | Always use zodResolver with Zod | | Missing FormField wrapper | No error handling, poor UX | Wrap inputs in FormField | | Not checking formState.isSubmitting | Button not disabled during submit | Use isSubmitting to disable button | | Using register without FormField | No shadcn/ui integration | Use FormField with Controller | | Not calling reset() after success | Form not cleared | Call form.reset() on successful submit | | Complex validation in component | Hard to test, maintain | Define schema with Zod | | Not handling mutation errors | Silent failures | Use onError in mutation |

Quick Troubleshooting

| Issue | Likely Cause | Fix | |-------|--------------|-----| | Validation not working | Missing zodResolver | Add resolver: zodResolver(schema) to useForm | | Errors not displaying | Missing FormMessage | Add <FormMessage /> in FormField | | Form not resetting | Not calling reset() | Call form.reset() after successful submit | | Submit button not disabling | Not using isSubmitting | Use form.formState.isSubmitting | | Custom component not working | Not using Controller | Wrap with Controller in FormField | | Default values not showing | Not in defaultValues | Add to defaultValues in useForm | | Type errors | Schema not matching FormData type | Ensure z.infer<typeof schema> matches interface |

Reference Documentation