React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

formsloginsignupcontactpatterns

Form Patterns Basic

Copy-paste patterns for the most common forms — login, signup, and contact — with Zod validation and proper UX.

Recipe

Quick-reference recipe card — copy-paste ready.

"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
// Login schema
const LoginSchema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(1, "Password required"),
});
 
type LoginData = z.infer<typeof LoginSchema>;
 
function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => Promise<void> }) {
  const { register, handleSubmit, formState: { errors, isSubmitting }, setError } = useForm<LoginData>({
    resolver: zodResolver(LoginSchema),
  });
 
  async function handleLogin(data: LoginData) {
    try {
      await onSubmit(data);
    } catch {
      setError("root", { message: "Invalid email or password" });
    }
  }
 
  return (
    <form onSubmit={handleSubmit(handleLogin)}>
      {errors.root && <p className="text-red-600">{errors.root.message}</p>}
      <input {...register("email")} type="email" placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}
      <input {...register("password")} type="password" placeholder="Password" />
      {errors.password && <p>{errors.password.message}</p>}
      <button disabled={isSubmitting}>{isSubmitting ? "Logging in..." : "Log in"}</button>
    </form>
  );
}

When to reach for this: When building standard authentication or contact flows — these patterns cover 80% of forms in a typical app.

Working Example

"use client";
 
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
// --- Signup Form ---
 
const SignupSchema = z
  .object({
    name: z.string().min(2, "Name must be at least 2 characters"),
    email: z.string().email("Please enter a valid email"),
    password: z
      .string()
      .min(8, "At least 8 characters")
      .regex(/[A-Z]/, "Need an uppercase letter")
      .regex(/[0-9]/, "Need a number"),
    confirmPassword: z.string(),
    terms: z.literal(true, { errorMap: () => ({ message: "You must accept the terms" }) }),
  })
  .refine((d) => d.password === d.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  });
 
type SignupData = z.infer<typeof SignupSchema>;
 
export function SignupForm() {
  const [success, setSuccess] = useState(false);
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm<SignupData>({
    resolver: zodResolver(SignupSchema),
    defaultValues: { name: "", email: "", password: "", confirmPassword: "", terms: false as any },
  });
 
  async function onSubmit(data: SignupData) {
    try {
      const res = await fetch("/api/signup", {
        method: "POST",
        body: JSON.stringify(data),
        headers: { "Content-Type": "application/json" },
      });
      if (!res.ok) {
        const body = await res.json();
        if (body.field) {
          setError(body.field, { message: body.message });
        } else {
          setError("root", { message: body.message || "Something went wrong" });
        }
        return;
      }
      setSuccess(true);
    } catch {
      setError("root", { message: "Network error. Please try again." });
    }
  }
 
  if (success) {
    return <p className="text-green-600 font-medium">Account created! Check your email.</p>;
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
      {errors.root && (
        <div className="rounded bg-red-50 p-3 text-sm text-red-700">{errors.root.message}</div>
      )}
 
      <div>
        <label htmlFor="name" className="block text-sm font-medium">Name</label>
        <input id="name" {...register("name")} className="w-full rounded border p-2" />
        {errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
      </div>
 
      <div>
        <label htmlFor="email" className="block text-sm font-medium">Email</label>
        <input id="email" {...register("email")} type="email" className="w-full rounded border p-2" />
        {errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
      </div>
 
      <div>
        <label htmlFor="password" className="block text-sm font-medium">Password</label>
        <input id="password" {...register("password")} type="password" className="w-full rounded border p-2" />
        {errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
      </div>
 
      <div>
        <label htmlFor="confirm" className="block text-sm font-medium">Confirm Password</label>
        <input id="confirm" {...register("confirmPassword")} type="password" className="w-full rounded border p-2" />
        {errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
      </div>
 
      <div className="flex items-center gap-2">
        <input id="terms" type="checkbox" {...register("terms")} />
        <label htmlFor="terms" className="text-sm">I accept the terms and conditions</label>
      </div>
      {errors.terms && <p className="text-sm text-red-600">{errors.terms.message}</p>}
 
      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isSubmitting ? "Creating account..." : "Create Account"}
      </button>
    </form>
  );
}

What this demonstrates:

  • Complete signup form with password confirmation
  • Terms checkbox with z.literal(true) validation
  • Server error handling with setError for field-level and root errors
  • Success state transition
  • Accessible labels with htmlFor

Deep Dive

How It Works

  • zodResolver connects the Zod schema to react-hook-form's validation pipeline
  • setError("root", ...) sets a form-level error (e.g., "Invalid credentials") not tied to a field
  • setError("email", ...) sets a field-level error from server responses (e.g., "Email already taken")
  • isSubmitting is true while the onSubmit promise is pending — use it to disable the button and show loading text
  • z.literal(true) for checkboxes ensures the box must be checked

Variations

Contact form with subject dropdown:

const ContactSchema = z.object({
  name: z.string().min(1, "Name required"),
  email: z.string().email(),
  subject: z.enum(["general", "support", "billing", "partnership"]),
  message: z.string().min(10).max(2000),
  priority: z.enum(["low", "normal", "high"]).default("normal"),
});

Login with OAuth buttons:

function LoginPage() {
  return (
    <div className="space-y-4">
      <LoginForm onSubmit={handleEmailLogin} />
      <div className="relative text-center text-sm text-gray-500">
        <span className="bg-white px-2">or continue with</span>
      </div>
      <div className="flex gap-2">
        <button onClick={() => signIn("google")} className="flex-1 rounded border p-2">Google</button>
        <button onClick={() => signIn("github")} className="flex-1 rounded border p-2">GitHub</button>
      </div>
    </div>
  );
}

Forgot password flow:

const ForgotSchema = z.object({ email: z.string().email() });
const ResetSchema = z
  .object({
    token: z.string(),
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((d) => d.password === d.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  });

TypeScript Notes

// Server error response typing
type ApiError = { field?: keyof SignupData; message: string };
 
// Type-safe setError
const { setError } = useForm<SignupData>();
setError("email", { message: "Taken" });   // OK
setError("typo", { message: "..." });      // TS error
 
// Reusable form field component
function Field<T extends FieldValues>({
  name, label, form, type = "text",
}: {
  name: FieldPath<T>;
  label: string;
  form: UseFormReturn<T>;
  type?: string;
}) {
  return (
    <div>
      <label className="block text-sm font-medium">{label}</label>
      <input type={type} {...form.register(name)} className="w-full rounded border p-2" />
      {form.formState.errors[name] && (
        <p className="text-sm text-red-600">{form.formState.errors[name]?.message as string}</p>
      )}
    </div>
  );
}

Gotchas

  • Generic "Invalid credentials" for login — Never reveal whether the email or password was wrong. Fix: Always show "Invalid email or password" as a root error.

  • Password field not preserved on error — Browsers clear password fields on form resubmission. This is expected security behavior; do not try to repopulate passwords.

  • Checkbox with registerregister for checkboxes works but returns a string "on" or undefined. Fix: Use z.literal(true) and ensure the checkbox value maps to a boolean, or use Controller.

  • Rate limiting — Login and signup forms are prime targets for brute force. Fix: Add rate limiting on the server and show appropriate error messages.

Alternatives

AlternativeUse WhenDon't Use When
Server Action formsYou want progressive enhancement without client JSYou need instant field validation
Auth.js (NextAuth)You need full auth with OAuth, sessions, etc.You only need a simple login form
Clerk / Auth0You want managed auth with pre-built UIYou need full control over the auth flow
shadcn FormYou want polished UI with minimal effortYou are building a headless form

FAQs

How does z.literal(true) work for checkbox validation?
  • z.literal(true) requires the value to be exactly true, not just truthy
  • Use it for "accept terms" checkboxes where the user must check the box
  • Provide a custom error message via errorMap: z.literal(true, { errorMap: () => ({ message: "You must accept" }) })
What does setError("root", ...) do and when should you use it?
  • It sets a form-level error not tied to any specific field
  • Access it via errors.root?.message
  • Use it for generic server errors like "Invalid email or password" on login forms
How do you set a field-level error from a server response?
const body = await res.json();
if (body.field) {
  setError(body.field, { message: body.message });
}
  • setError("email", { message: "Already taken" }) attaches the error to the email field
  • The error displays the same way as a validation error
Why use .refine() for password confirmation instead of validating each field separately?
  • .refine() operates on the whole object, so it can compare password and confirmPassword
  • Single-field validators only see their own value
  • Use path: ["confirmPassword"] to attach the error to the correct field
What is the purpose of isSubmitting and how does it work?
  • isSubmitting is true while the onSubmit promise is pending
  • Use it to disable the submit button and show loading text like "Creating account..."
  • It automatically resets to false when the promise resolves or rejects
Gotcha: Why should login forms always show "Invalid email or password" instead of specific errors?
  • Revealing whether the email or password was wrong helps attackers confirm valid accounts
  • Always use a generic root error: setError("root", { message: "Invalid email or password" })
  • This is a security best practice for all authentication forms
Gotcha: Why does the register function not work well with checkboxes by default?
  • register on a checkbox returns "on" or undefined instead of a boolean
  • Zod's z.literal(true) expects a boolean, causing a type mismatch
  • Fix: use Controller for checkboxes, or ensure the value maps to a boolean
How do you type a reusable form field component with TypeScript generics?
function Field<T extends FieldValues>({
  name, label, form,
}: {
  name: FieldPath<T>;
  label: string;
  form: UseFormReturn<T>;
}) {
  return <input {...form.register(name)} />;
}
  • FieldPath<T> constrains name to valid field paths of the form type
How do you type the server error response for setError in TypeScript?
type ApiError = { field?: keyof SignupData; message: string };
  • Constraining field to keyof SignupData ensures only valid field names can be passed to setError
How does the success state transition work in the signup form?
  • A useState(false) boolean tracks whether signup succeeded
  • On success, set it to true and render a success message instead of the form
  • The entire form is replaced, preventing double submission
What is the zodResolver and why is it needed?
  • zodResolver(Schema) adapts a Zod schema to react-hook-form's resolver interface
  • It runs schema.safeParse() and maps Zod errors to RHF's FieldErrors format
  • Install it from @hookform/resolvers/zod