logoPressFast

Forms

Form components and validation

Forms

Build forms with validation using React Hook Form and Zod.

Installation

pnpm add react-hook-form zod @hookform/resolvers

Basic Form

'use client';

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

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

type FormData = z.infer<typeof formSchema>;

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

  const onSubmit = async (data: FormData) => {
    console.log(data);
    // Handle form submission
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium mb-2">
          Email
        </label>
        <Input
          id="email"
          type="email"
          {...register('email')}
          className={errors.email ? 'border-red-500' : ''}
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium mb-2">
          Password
        </label>
        <Input
          id="password"
          type="password"
          {...register('password')}
          className={errors.password ? 'border-red-500' : ''}
        />
        {errors.password && (
          <p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
        )}
      </div>

      <Button type="submit" disabled={isSubmitting} className="w-full">
        {isSubmitting ? 'Submitting...' : 'Sign In'}
      </Button>
    </form>
  );
}

Form with shadcn/ui

import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';

export function ProfileForm() {
  const form = useForm<FormData>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: '',
      email: '',
    },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl>
                <Input placeholder="John Doe" {...field} />
              </FormControl>
              <FormDescription>
                Your public display name
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="john@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">Update Profile</Button>
      </form>
    </Form>
  );
}

Input Components

Text Input

<Input
  type="text"
  placeholder="Enter text"
  {...register('fieldName')}
/>

Textarea

<Textarea
  placeholder="Enter description"
  rows={5}
  {...register('description')}
/>

Select

<select {...register('country')} className="border rounded px-3 py-2">
  <option value="">Select country</option>
  <option value="us">United States</option>
  <option value="uk">United Kingdom</option>
</select>

Checkbox

<div className="flex items-center space-x-2">
  <input
    type="checkbox"
    id="terms"
    {...register('terms')}
    className="rounded border-gray-300"
  />
  <label htmlFor="terms" className="text-sm">
    I agree to the terms and conditions
  </label>
</div>

Radio

<div className="space-y-2">
  <div className="flex items-center space-x-2">
    <input
      type="radio"
      id="option1"
      value="option1"
      {...register('choice')}
    />
    <label htmlFor="option1">Option 1</label>
  </div>
  <div className="flex items-center space-x-2">
    <input
      type="radio"
      id="option2"
      value="option2"
      {...register('choice')}
    />
    <label htmlFor="option2">Option 2</label>
  </div>
</div>

Complex Validation

const schema = z.object({
  email: z.string().email(),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain uppercase letter')
    .regex(/[0-9]/, 'Password must contain a number'),
  confirmPassword: z.string(),
  age: z.number().min(18, 'Must be at least 18'),
  website: z.string().url().optional(),
  terms: z.boolean().refine((val) => val === true, {
    message: 'You must accept the terms',
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

File Upload

const fileSchema = z.object({
  file: z.instanceof(FileList).refine((files) => files.length > 0, {
    message: 'File is required',
  }),
});

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

  const onSubmit = async (data: any) => {
    const file = data.file[0];
    const formData = new FormData();
    formData.append('file', file);

    await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    });
  };

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

Dynamic Fields

import { useFieldArray } from 'react-hook-form';

export function DynamicForm() {
  const { control, register } = useForm({
    defaultValues: {
      items: [{ name: '', quantity: 0 }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items',
  });

  return (
    <form>
      {fields.map((field, index) => (
        <div key={field.id} className="flex gap-2">
          <Input {...register(`items.${index}.name`)} placeholder="Item name" />
          <Input
            type="number"
            {...register(`items.${index}.quantity`, { valueAsNumber: true })}
            placeholder="Quantity"
          />
          <Button type="button" onClick={() => remove(index)}>
            Remove
          </Button>
        </div>
      ))}
      <Button
        type="button"
        onClick={() => append({ name: '', quantity: 0 })}
      >
        Add Item
      </Button>
    </form>
  );
}

Server Actions

'use client';

import { useFormState } from 'react-dom';
import { createUser } from '@/app/actions/user';

export function ServerActionForm() {
  const [state, formAction] = useFormState(createUser, null);

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name">Name</label>
        <Input id="name" name="name" required />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <Input id="email" name="email" type="email" required />
      </div>
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      <Button type="submit">Create User</Button>
    </form>
  );
}

Best Practices

  1. Validate on both sides: Client and server
  2. Clear error messages: Help users fix errors
  3. Loading states: Show feedback during submission
  4. Disable on submit: Prevent duplicate submissions
  5. Reset on success: Clear form after successful submission
  6. Accessible labels: Use proper form labels
  7. Focus management: Focus on first error field

Next Steps

Forms