Dependencies
tailwindcssshadcn/uireact-hook-formzod@hookform/resolvers
shadcn/ui components
npx shadcn@latest add formnpx shadcn@latest add inputnpx shadcn@latest add buttonnpx shadcn@latest add labelnpx shadcn@latest add select

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

ToolPurpose
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.errorsObject of field error messages

1. Login Form with Zod Validation

Email and password validation with error messages using shadcn/ui Form.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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

Define a Zod schema with z.object({}), then pass it to zodResolver from @hookform/resolvers/zod. Use useForm({ resolver: zodResolver(schema) }) to get the form methods. Errors appear in formState.errors keyed by field name.
Wrap each field in a FormField with a Controller, then use FormItem, FormLabel, FormControl, and FormMessage. FormMessage automatically reads and displays the error for that field from React Hook Form's form state.
Pass mode to useForm: useForm({ mode: 'onBlur' }) validates each field when it loses focus. mode: 'onChange' validates on every keystroke. mode: 'onSubmit' (default) only validates on form submission. Use mode: 'all' to validate on both blur and change.

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.

Related Components