React form validation with React Hook Form, Zod and shadcn/ui. Five patterns from basic login validation to async username checking and multi-step wizards.
Key Classes Reference
| Tool | Purpose |
|---|---|
useForm({ resolver: zodResolver(schema) }) | Connect Zod schema to React Hook Form |
z.object({}) | Define field shapes and rules |
.refine() | Cross-field validation (e.g. password match) |
mode: "onBlur" | Validate when field loses focus |
mode: "onChange" | Validate on every keystroke |
form.trigger() | Programmatically trigger validation |
formState.errors | Object of field error messages |
1. Login Form with Zod Validation
Email and password validation with error messages using shadcn/ui Form.
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
const schema = z.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
type FormData = z.infer<typeof schema>
export default function LoginValidation() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
})
function onSubmit(data: FormData) {
console.log("Login:", data)
}
return (
<div className="p-6 max-w-sm mx-auto">
<h2 className="text-xl font-bold mb-6">Sign In</h2>
<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 type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full bg-red-500 hover:bg-red-600 text-white">
Sign In
</Button>
</form>
</Form>
</div>
)
}2. Registration Form
Full registration form with password confirmation and cross-field validation.
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Enter a valid email"),
password: z.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "Include at least one uppercase letter")
.regex(/[0-9]/, "Include at least one number"),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
type FormData = z.infer<typeof schema>
export default function RegistrationForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
mode: "onBlur",
})
const password = form.watch("password")
const strength = !password ? 0
: [/.{8,}/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(r => r.test(password)).length
const strengthLabel = ["", "Weak", "Fair", "Good", "Strong"][strength]
const strengthColor = ["", "bg-red-400", "bg-orange-400", "bg-yellow-400", "bg-green-500"][strength]
return (
<div className="p-6 max-w-sm mx-auto">
<h2 className="text-xl font-bold mb-6">Create Account</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit((d) => console.log(d))} className="space-y-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl><Input placeholder="Gagan Singh" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" placeholder="you@example.com" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="password" render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl><Input type="password" placeholder="••••••••" {...field} /></FormControl>
{password && (
<div className="space-y-1">
<div className="flex gap-1">
{[1,2,3,4].map((i) => (
<div key={i} className={`h-1 flex-1 rounded-full transition-colors ${i <= strength ? strengthColor : "bg-gray-200"}`} />
))}
</div>
<p className="text-xs text-muted-foreground">Password strength: {strengthLabel}</p>
</div>
)}
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="confirmPassword" render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl><Input type="password" placeholder="••••••••" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<Button type="submit" className="w-full bg-red-500 hover:bg-red-600 text-white">
Create Account
</Button>
</form>
</Form>
</div>
)
}3. Inline Real-Time Validation
Field validates on every keystroke with colour-coded feedback.
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { CheckCircle2, XCircle } from "lucide-react"
const schema = z.object({
username: z.string()
.min(3, "At least 3 characters")
.max(20, "Max 20 characters")
.regex(/^[a-z0-9_]+$/, "Lowercase letters, numbers and underscores only"),
email: z.string().email("Enter a valid email"),
})
type FormData = z.infer<typeof schema>
function FieldStatus({ valid, error }: { valid: boolean; error?: string }) {
if (!error && !valid) return null
return valid
? <p className="flex items-center gap-1 text-xs text-green-600 mt-1"><CheckCircle2 className="h-3 w-3" />Looks good!</p>
: <p className="flex items-center gap-1 text-xs text-red-500 mt-1"><XCircle className="h-3 w-3" />{error}</p>
}
export default function InlineValidation() {
const { register, formState: { errors, touchedFields, dirtyFields } } = useForm<FormData>({
resolver: zodResolver(schema),
mode: "onChange",
})
return (
<div className="p-6 max-w-sm mx-auto space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
{...register("username")}
placeholder="gagan_singh"
className={`w-full border-2 rounded-xl px-4 py-2 text-sm outline-none transition-colors ${
errors.username ? "border-red-400 focus:border-red-500"
: dirtyFields.username ? "border-green-400 focus:border-green-500"
: "border-gray-200 focus:border-red-400"
}`}
/>
<FieldStatus
valid={dirtyFields.username && !errors.username}
error={errors.username?.message}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
{...register("email")}
type="email"
placeholder="you@example.com"
className={`w-full border-2 rounded-xl px-4 py-2 text-sm outline-none transition-colors ${
errors.email ? "border-red-400 focus:border-red-500"
: dirtyFields.email ? "border-green-400 focus:border-green-500"
: "border-gray-200 focus:border-red-400"
}`}
/>
<FieldStatus
valid={dirtyFields.email && !errors.email}
error={errors.email?.message}
/>
</div>
</div>
)
}4. Async Validation (Username Check)
Validates field asynchronously against an API — shows checking state.
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Loader2, CheckCircle2, XCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
const schema = z.object({
username: z.string().min(3, "At least 3 characters"),
})
type FormData = z.infer<typeof schema>
const taken = ["admin", "gagan", "lettstartdesign"]
async function checkUsername(username: string): Promise<boolean> {
await new Promise(r => setTimeout(r, 800)) // simulate API
return !taken.includes(username.toLowerCase())
}
export default function AsyncValidation() {
const [status, setStatus] = useState<"idle" | "checking" | "available" | "taken">("idle")
const { register, handleSubmit, formState: { errors }, getValues } = useForm<FormData>({
resolver: zodResolver(schema),
})
async function handleBlur() {
const val = getValues("username")
if (val.length < 3) return
setStatus("checking")
const available = await checkUsername(val)
setStatus(available ? "available" : "taken")
}
return (
<div className="p-6 max-w-sm mx-auto">
<div className="space-y-2">
<label className="block text-sm font-medium">Username</label>
<div className="relative">
<input
{...register("username")}
onBlur={handleBlur}
placeholder="try 'admin' or 'gagan'"
className="w-full border-2 rounded-xl px-4 py-2 pr-10 text-sm outline-none border-gray-200 focus:border-red-400 transition-colors"
/>
<div className="absolute right-3 top-2.5">
{status === "checking" && <Loader2 className="h-4 w-4 animate-spin text-gray-400" />}
{status === "available" && <CheckCircle2 className="h-4 w-4 text-green-500" />}
{status === "taken" && <XCircle className="h-4 w-4 text-red-500" />}
</div>
</div>
{errors.username && <p className="text-xs text-red-500">{errors.username.message}</p>}
{status === "available" && <p className="text-xs text-green-600">Username is available!</p>}
{status === "taken" && <p className="text-xs text-red-500">Username already taken.</p>}
</div>
<Button className="mt-4 w-full bg-red-500 hover:bg-red-600 text-white"
disabled={status === "taken" || status === "checking"}>
Continue
</Button>
</div>
)
}5. Multi-Step Form Validation
Three-step wizard form — each step validates before proceeding.
import { useState } from "react"
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"
import { Check } from "lucide-react"
const step1 = z.object({ name: z.string().min(2), email: z.string().email() })
const step2 = z.object({ company: z.string().min(2), role: z.string().min(2) })
const step3 = z.object({ password: z.string().min(8), confirm: z.string() })
.refine(d => d.password === d.confirm, { message: "Passwords don't match", path: ["confirm"] })
const schemas = [step1, step2, step3]
export default function MultiStepForm() {
const [step, setStep] = useState(0)
const [completed, setCompleted] = useState(false)
const form = useForm({ resolver: zodResolver(schemas[step]) })
async function next() {
const valid = await form.trigger()
if (valid) {
if (step === 2) setCompleted(true)
else setStep(s => s + 1)
}
}
if (completed) {
return (
<div className="p-6 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="h-8 w-8 text-green-500" />
</div>
<h3 className="font-bold text-lg">All done!</h3>
<p className="text-sm text-muted-foreground mt-1">Account created successfully.</p>
</div>
)
}
return (
<div className="p-6 max-w-sm mx-auto">
<div className="flex items-center gap-2 mb-6">
{["Account", "Company", "Security"].map((label, i) => (
<div key={label} className="flex items-center gap-2">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold ${
i < step ? "bg-green-500 text-white" : i === step ? "bg-red-500 text-white" : "bg-gray-100 text-gray-400"
}`}>
{i < step ? <Check className="h-3 w-3" /> : i + 1}
</div>
<span className={`text-xs font-medium ${i === step ? "text-red-500" : "text-gray-400"}`}>{label}</span>
{i < 2 && <div className={`h-px w-6 ${i < step ? "bg-green-400" : "bg-gray-200"}`} />}
</div>
))}
</div>
<div className="space-y-4">
{step === 0 && <>
<div>
<label className="block text-sm font-medium mb-1">Full Name</label>
<Input {...form.register("name")} placeholder="Gagan Singh" />
{form.formState.errors.name && <p className="text-xs text-red-500 mt-1">{String(form.formState.errors.name.message)}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<Input {...form.register("email")} type="email" placeholder="you@example.com" />
{form.formState.errors.email && <p className="text-xs text-red-500 mt-1">{String(form.formState.errors.email.message)}</p>}
</div>
</>}
{step === 1 && <>
<div>
<label className="block text-sm font-medium mb-1">Company</label>
<Input {...form.register("company")} placeholder="LettStart Design" />
{form.formState.errors.company && <p className="text-xs text-red-500 mt-1">{String(form.formState.errors.company.message)}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Role</label>
<Input {...form.register("role")} placeholder="Frontend Developer" />
{form.formState.errors.role && <p className="text-xs text-red-500 mt-1">{String(form.formState.errors.role.message)}</p>}
</div>
</>}
{step === 2 && <>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<Input {...form.register("password")} type="password" placeholder="••••••••" />
{form.formState.errors.password && <p className="text-xs text-red-500 mt-1">{String(form.formState.errors.password.message)}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<Input {...form.register("confirm")} type="password" placeholder="••••••••" />
{form.formState.errors.confirm && <p className="text-xs text-red-500 mt-1">{String(form.formState.errors.confirm.message)}</p>}
</div>
</>}
</div>
<div className="flex gap-3 mt-6">
{step > 0 && <Button variant="outline" onClick={() => setStep(s => s - 1)} className="flex-1">Back</Button>}
<Button onClick={next} className="flex-1 bg-red-500 hover:bg-red-600 text-white">
{step === 2 ? "Create Account" : "Next"}
</Button>
</div>
</div>
)
}Frequently Asked Questions
Need a Full React + Next.js Dashboard Template?
Get a complete React + Next.js dashboard with 50+ components — built by the same team behind BootstrapPlanet.
Browse Templates →Use code FIRST30 for 30% off.