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
setErrorfor field-level and root errors - Success state transition
- Accessible labels with
htmlFor
Deep Dive
How It Works
zodResolverconnects the Zod schema to react-hook-form's validation pipelinesetError("root", ...)sets a form-level error (e.g., "Invalid credentials") not tied to a fieldsetError("email", ...)sets a field-level error from server responses (e.g., "Email already taken")isSubmittingistruewhile theonSubmitpromise is pending — use it to disable the button and show loading textz.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
register—registerfor checkboxes works but returns a string"on"orundefined. Fix: Usez.literal(true)and ensure the checkbox value maps to a boolean, or useController. -
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
| Alternative | Use When | Don't Use When |
|---|---|---|
| Server Action forms | You want progressive enhancement without client JS | You need instant field validation |
| Auth.js (NextAuth) | You need full auth with OAuth, sessions, etc. | You only need a simple login form |
| Clerk / Auth0 | You want managed auth with pre-built UI | You need full control over the auth flow |
| shadcn Form | You want polished UI with minimal effort | You are building a headless form |
FAQs
How does z.literal(true) work for checkbox validation?
z.literal(true)requires the value to be exactlytrue, 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 comparepasswordandconfirmPassword- 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?
isSubmittingistruewhile theonSubmitpromise is pending- Use it to disable the submit button and show loading text like "Creating account..."
- It automatically resets to
falsewhen 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?
registeron a checkbox returns"on"orundefinedinstead of a boolean- Zod's
z.literal(true)expects a boolean, causing a type mismatch - Fix: use
Controllerfor 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>constrainsnameto 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
fieldtokeyof SignupDataensures only valid field names can be passed tosetError
How does the success state transition work in the signup form?
- A
useState(false)boolean tracks whether signup succeeded - On success, set it to
trueand 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'sFieldErrorsformat - Install it from
@hookform/resolvers/zod
Related
- RHF + Zod — resolver integration details
- Form Patterns Complex — multi-step, dynamic fields
- Form Error Display — error display patterns
- Form Accessibility — ARIA and focus management