Build forms that validate themselves — controlled inputs, Zod schemas that check data at runtime, and React Hook Form wired to Zod for typed, validated forms.
Why: a controlled input mirrors its value into state — React state is the single source of truth, so you can validate, transform, or disable as the user types. Fine for one or two fields; for bigger forms use React Hook Form below.
// app/signup/page.tsx — lives at /signup
'use client'
import { useState } from 'react'
export default function Signup() {
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() // stop the browser's full-page reload
setSubmitted(true)
}
// After submit, greet the user by the part before the @
if (submitted) return <p>Welcome, {email.split('@')[0]}</p>
return (
<form onSubmit={handleSubmit}>
{/* value + onChange = controlled */}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
<button disabled={!email.includes('@')}>Sign up</button>
</form>
)
}Why: TypeScript types vanish at runtime — fetch responses and form input are typed as whatever you claim. A Zod schema actually checks the data, and z.infer derives the static type from it so you never write the shape twice.
$ pnpm add zodimport { z } from 'zod'
// The schema both describes the shape and checks real data against it
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'member']).default('member'),
})
type User = z.infer<typeof UserSchema> // static type, derived for free
async function loadUser(): Promise<User | null> {
const raw = await fetch('/api/me').then((r) => r.json())
// .safeParse returns a result object; .parse throws instead
const result = UserSchema.safeParse(raw)
if (!result.success) {
console.log(result.error.issues)
return null
}
return result.data // fully typed as User
}Why: React Hook Form manages registration, errors, and submit state with almost no re-renders, and the Zod resolver plugs your schema in as the validation source — one schema drives the types, the rules, and the error messages.
$ pnpm add react-hook-form zod @hookform/resolvers'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
})
type FormData = z.infer<typeof schema>
export default function Login() {
const {
register, // wires an input to the form by its name
handleSubmit, // validates first, then calls your function
formState: { errors, isSubmitting },
} = useForm<FormData>({ resolver: zodResolver(schema) }) // schema = the rules
return (
// Your callback only runs when the data passes the schema
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('email')} placeholder="Email" />
{/* The message comes straight from the schema above */}
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" placeholder="Password" />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isSubmitting}>Log in</button>
</form>
)
}