This document explains every concept and line of code used in components/login-form.tsx, including Zod, React Hook Form, and how they work together.
Zod is a TypeScript-first schema declaration and validation library. You describe the shape and rules of your data once, and Zod handles both TypeScript type inference and runtime validation — no duplication needed.
| Concept | Description |
|---|---|
z.object({}) |
Defines a schema for a plain JavaScript object with named fields |
z.string() |
Declares a field must be a string |
.email(message) |
Adds a validation rule: the string must be a valid email format |
.min(n, message) |
Adds a validation rule: the string must be at least n characters long |
z.infer<typeof schema> |
Extracts the TypeScript type from a Zod schema so you don't write it twice |
const formSchema = z.object({
email: z.string().email("Please enter a valid email address."),
password: z.string().min(8, "Password must be at least 8 characters."),
})This schema says:
emailmust be a string AND a valid email address.passwordmust be a string AND at least 8 characters long.
If either rule fails, Zod produces an error object with the message you provided.
React Hook Form (RHF) is a library for managing form state in React. It tracks field values, validation errors, submission state, and dirty/touched state — with minimal re-renders because it uses uncontrolled inputs under the hood.
| Concept | Description |
|---|---|
useForm() |
The main hook. Returns methods and state for managing the form |
form.control |
An object passed to <Controller /> so RHF can manage individual fields |
form.handleSubmit(fn) |
Wraps your submit handler — runs validation first, calls fn only if valid |
form.reset() |
Resets all fields back to their defaultValues |
<Controller /> |
A wrapper component for integrating controlled third-party inputs with RHF |
field (from Controller) |
Props you spread onto the input: name, value, onChange, onBlur, ref |
fieldState (from Controller) |
Validation state for that field: invalid, error, isDirty, isTouched |
resolver |
A plugin that connects an external validation library (like Zod) to RHF |
RHF handles form state. Zod handles validation rules. The zodResolver from @hookform/resolvers/zod is the bridge between them.
User submits form
↓
form.handleSubmit fires
↓
zodResolver runs the Zod schema against the form values
↓
Valid? → onSubmit(data) is called with typed, validated data
Invalid? → fieldState.error is populated per field, UI shows errors
This separation keeps concerns clear: Zod owns "what is valid", RHF owns "how the form behaves".
"use client"Marks this as a React Client Component (Next.js App Router). Required because it uses hooks (useForm) which only run in the browser.
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"zodResolver— the adapter that lets RHF use a Zod schema for validation.Controller— RHF component for wrapping controlled inputs.useForm— the core RHF hook.z— the Zod namespace; all Zod methods are accessed through it.
import { cn } from "@/lib/utils"A utility function that merges Tailwind class names conditionally (combines clsx and tailwind-merge).
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "..."
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"shadcn UI components used to build the form's visual structure.
const formSchema = z.object({
email: z.string().email("Please enter a valid email address."),
password: z.string().min(8, "Password must be at least 8 characters."),
})This is the single source of truth for validation logic. It lives outside the component so it is not recreated on every render.
z.string()— both fields must be strings (notundefined,null, or a number)..email(message)— validates the string against an email regex..min(8, message)— rejects strings shorter than 8 characters.- The string passed to each validator is the error message shown to the user when that rule fails.
type FormValues = z.infer<typeof formSchema>z.infer reads the Zod schema and produces an equivalent TypeScript type:
// Equivalent to writing this manually:
type FormValues = {
email: string
password: string
}Using z.infer means the type and the validation rules can never fall out of sync — change the schema, the type updates automatically.
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
})useForm<FormValues>— the generic tells TypeScript what shape the form data has.resolver: zodResolver(formSchema)— plugs the Zod schema into RHF's validation pipeline. When a submission occurs (or on configured trigger events), RHF runs the schema against the current values.defaultValues— the initial values for each field. Providing these prevents React warnings about switching between controlled and uncontrolled inputs, and also determines whatform.reset()restores fields to.
function onSubmit(data: FormValues) {
console.log(data)
}This function is only called when all Zod validations pass. The data parameter is fully typed as FormValues — TypeScript knows data.email is a string and it is a valid email. In a real app, this is where you would call an API, redirect, or store the user session.
<form onSubmit={form.handleSubmit(onSubmit)}>form.handleSubmit(onSubmit) is an event handler that:
- Prevents the default browser form submission.
- Runs all validations via the Zod resolver.
- If valid — calls
onSubmit(data). - If invalid — updates
fieldState.errorfor each failing field, triggering a re-render that shows error messages.
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
...
)}
/><Controller /> is required here because the <Input /> is a custom shadcn component, not a raw <input>. It registers the field with RHF and provides two objects via render props:
field — wire up the input:
| Prop | Purpose |
|---|---|
field.name |
The field's key in the form ("email" or "password") |
field.value |
The current value — kept in sync by RHF |
field.onChange |
Notifies RHF when the value changes |
field.onBlur |
Notifies RHF when the input loses focus |
field.ref |
Allows RHF to focus the input on validation errors |
Spreading {...field} onto <Input /> passes all of the above at once.
fieldState — read validation state:
| Prop | Purpose |
|---|---|
fieldState.invalid |
true if this field currently has a validation error |
fieldState.error |
The error object { message: string } from Zod, or undefined |
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input ... />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>These are shadcn UI primitives:
<Field>— a<div role="group">container. Thedata-invalidattribute enables CSS error styling (red border, red label text) defined in the component's Tailwind classes.<FieldLabel>— a styled<label>.htmlFor={field.name}links it to the input for accessibility, so clicking the label focuses the input.<FieldError>— renders the error message. It accepts anerrorsarray of{ message?: string }objects and displays the message. Only rendered whenfieldState.invalidistrueto avoid empty elements in the DOM.
<Input
{...field}
id={field.name}
type="email"
placeholder="m@example.com"
aria-invalid={fieldState.invalid}
/>{...field}— spreadsname,value,onChange,onBlur,reffrom RHF onto the input.id={field.name}— matches thehtmlForon<FieldLabel>, linking the two for accessibility.type="email"/type="password"— standard HTML input types for browser behaviour (mobile keyboard hints, password masking).aria-invalid={fieldState.invalid}— communicates the error state to screen readers. Whentrue, assistive technology announces the field as invalid.
<Field>
<Button type="submit">Login</Button>
</Field>A standard type="submit" button. When clicked, it triggers the form's onSubmit event, which RHF intercepts via form.handleSubmit. No manual click handler is needed.