Forms & Validation Basics
13 examples to get you started with Forms & Validation -- 8 basic and 5 intermediate.
Prerequisites
Most examples assume a Next.js 15+ App Router project with TypeScript. The non-trivial forms use Zod and react-hook-form:
npm install zod react-hook-form @hookform/resolversConventions used throughout:
- Validation schemas are Zod objects -- single source of truth for runtime checks and compile-time types.
- Interactive forms are Client Components (
"use client"). Native<form action={serverAction}>forms can stay server-side. - Controlled inputs keep their
valuein React state; uncontrolled inputs read viaFormDataorref.
Choosing an approach? See the Decision Checklist to pick the right pattern for your form before writing code.
Basic Examples
1. Native Uncontrolled Form with FormData
The simplest React 19 form -- no state, no libraries, just HTML and FormData.
"use client";
export default function ContactForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
console.log({
name: data.get("name"),
email: data.get("email"),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>
);
}- Uncontrolled inputs skip the per-keystroke re-render -- cheap and perfect for simple forms.
new FormData(form)reads every named input in one call; you never need individual refs.required,type="email",minLength, and friends use the browser's built-in HTML5 validation.- In Next.js, swap
onSubmitfor<form action={serverAction}>to get progressive enhancement for free.
Related: Controlled vs Uncontrolled -- when to reach for each | Form Patterns Basic -- login, signup, contact templates
2. Controlled Input with useState
Keep the input value in React state when you need to validate, transform, or conditionally render based on it.
"use client";
import { useState } from "react";
export default function SearchBox() {
const [query, setQuery] = useState("");
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{query.length > 0 && <p>Typing: {query}</p>}
</div>
);
}- Controlled =
valueis state,onChangeupdates state -- React owns the input. - Fires a re-render every keystroke; for 20+ fields, prefer react-hook-form (uncontrolled under the hood).
- Great when the input drives other UI: live search, character counters, masked input, validation on every change.
- Always provide
value={state}-- neverdefaultValue-- or you'll get a "controlled-to-uncontrolled" warning.
Related: Controlled vs Uncontrolled -- full comparison and when to switch | React Hook Form -- uncontrolled forms that scale past a few fields
3. Zod Schema Validation
Define a runtime schema and parse incoming data -- throws or returns a typed object.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email("Invalid email"),
age: z.number().min(18, "Must be 18 or older"),
});
const result = UserSchema.safeParse({ email: "ada@example.com", age: 30 });
if (!result.success) {
console.log(result.error.flatten().fieldErrors);
} else {
// result.data is fully typed as { email: string; age: number }
console.log(result.data);
}safeParsereturns{ success, data | error }-- use it when you want to handle errors withouttry/catch.- Chained methods (
.email(),.min(),.max()) each add a rule and attach the error message. - The resulting schema is the single source of truth for both runtime validation and TypeScript types.
- Use
parseinstead when you're confident the data is valid and want it to throw on failure.
Related: Zod Basics -- schemas, errors, safeParse vs parse | Zod Types -- every primitive and composite type | Zod Transforms -- transform, refine, preprocess
4. Infer TypeScript Types from Zod
Derive the static type directly from the schema so there's only one thing to keep in sync.
import { z } from "zod";
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
price: z.number().positive(),
tags: z.array(z.string()).default([]),
});
type Product = z.infer<typeof ProductSchema>;
// { id: string; name: string; price: number; tags: string[] }z.infer<typeof Schema>reads the schema's output type -- rename a field once and both type and validator update together.z.inputgives you the input type (before transforms);z.outputgives the output type (after transforms).- Use this one type everywhere: component props, API bodies, database writes -- no drift.
- Start from the schema, not from a hand-written interface; otherwise you're writing the type twice.
Related: Zod Infer --
z.infervsz.inputvsz.output, transform pitfalls | Typing API Responses -- using schemas at fetch boundaries
5. React Hook Form Basic
Scale past a few fields with uncontrolled-under-the-hood forms and per-field registration.
"use client";
import { useForm } from "react-hook-form";
interface FormValues {
email: string;
password: string;
}
export default function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>();
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: "Email required" })} />
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register("password", { minLength: { value: 8, message: "Min 8 chars" } })}
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Log in</button>
</form>
);
}register(name, rules)wires an input to the form, tracks its value, and runs validation -- no state per field.- Inputs stay uncontrolled, so typing in one field does not re-render the others. Massive speedup past ~10 fields.
handleSubmitruns validation first; your callback only fires with clean, typed data.- For rich inputs (date pickers, selects from UI libraries), wrap them in RHF's
Controllerinstead ofregister.
Related: React Hook Form -- register, Controller, watch, reset | Form Patterns Basic -- login, signup, contact recipes
6. React Hook Form + Zod
Use a Zod schema as the single source of truth for validation and types.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const SignupSchema = z.object({
name: z.string().min(1, "Name required"),
email: z.string().email("Invalid email"),
age: z.coerce.number().min(18, "Must be 18+"),
});
type Signup = z.infer<typeof SignupSchema>;
export default function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Signup>({
resolver: zodResolver(SignupSchema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("age")} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Sign up</button>
</form>
);
}zodResolver(schema)wires Zod into RHF -- validation messages appear inerrors.<field>.messageautomatically.z.coerce.number()turns the string from<input>into a number, saving you a manual parse step.- One schema replaces two sources of truth (TS interface + inline RHF rules) -- refactor-friendly and concise.
- This is the recommended default for any form with 4+ fields or nontrivial validation.
Related: RHF + Zod -- deep dive, defaults, dynamic fields | shadcn Form -- same stack with accessible shadcn UI components
7. Server Action + Zod + useActionState
Validate on the server, return errors to the form, and track pending state with useActionState.
// app/contact/actions.ts
"use server";
import { z } from "zod";
const ContactSchema = z.object({
email: z.string().email(),
message: z.string().min(10),
});
type State = { ok: boolean; errors?: Record<string, string[]>; };
export async function submitContact(_prev: State | null, formData: FormData): Promise<State> {
const parsed = ContactSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await fetch("https://api.example.com/contacts", {
method: "POST",
body: JSON.stringify(parsed.data),
});
return { ok: true };
}// app/contact/form.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "./actions";
export default function ContactForm() {
const [state, action, isPending] = useActionState(submitContact, null);
return (
<form action={action}>
<input name="email" />
{state?.errors?.email && <p>{state.errors.email[0]}</p>}
<textarea name="message" />
{state?.errors?.message && <p>{state.errors.message[0]}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
</form>
);
}- Validate server-side even if you also validate client-side -- the client can be bypassed.
Object.fromEntries(formData)turnsFormDatainto a plain object Zod can parse.useActionStatereturns[state, action, isPending]-- wireactiondirectly into the form.- The action's return value becomes the new
state-- use it to render field-level errors.
Related: Server Action Forms -- full end-to-end pattern with redirects and revalidation | useActionState -- the hook's API | Server Actions -- the underlying primitive
8. Inline Error Display with ARIA
Show errors under each field in a way screen readers will announce.
"use client";
import { useState } from "react";
export default function EmailField() {
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const error = touched && !email.includes("@") ? "Please enter a valid email" : null;
const errorId = "email-error";
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}aria-invalidannounces that the input's current value is rejected.aria-describedbylinks the input to its error message so screen readers read them together.role="alert"on the error makes the announcement fire when the error appears, not before.- Defer errors until
onBluror submit so users are not yelled at mid-typing.
Related: Form Error Display -- inline, summary, and toast patterns | Form Accessibility -- ARIA, focus, and announcements in depth
Intermediate Examples
9. shadcn Form Component
Use shadcn's Form primitives to wire react-hook-form + Zod to accessible, styled UI in a few lines.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
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({
username: z.string().min(2).max(50),
});
export default function ProfileForm() {
const form = useForm<z.infer<typeof Schema>>({
resolver: zodResolver(Schema),
defaultValues: { username: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => console.log(v))}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
}- shadcn's
Formis a thin wrapper that threads RHF context through its subcomponents -- all the ARIA plumbing is automatic. FormMessagerenders the current field's error message without any manualerrors.<field>.messagelookup.- Ships as copy-paste source code in your repo, so you can tweak styles or behavior without forking a library.
- Requires a shadcn-configured project;
npx shadcn@latest add form input buttonscaffolds the components.
Related: shadcn Form -- full shadcn form recipe | shadcn Form (component) -- the UI primitives | RHF + Zod -- the stack underneath
10. Optimistic Form with useOptimistic
Show the new item instantly while the server action runs; roll back automatically on failure.
"use client";
import { useOptimistic, useRef } from "react";
interface Todo { id: string; title: string; }
export default function TodoList({
todos,
addTodo,
}: {
todos: Todo[];
addTodo: (title: string) => Promise<void>;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimistic, addOptimistic] = useOptimistic(
todos,
(state, next: Todo) => [...state, next]
);
const action = async (formData: FormData) => {
const title = formData.get("title") as string;
addOptimistic({ id: `temp-${Date.now()}`, title });
formRef.current?.reset();
await addTodo(title);
};
return (
<>
<ul>
{optimistic.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
<form ref={formRef} action={action}>
<input name="title" required />
<button type="submit">Add</button>
</form>
</>
);
}useOptimistic(state, reducer)returns a projected state; changes vanish automatically if the action throws or the server state replaces it.- The optimistic item gets a temporary ID (
temp-<timestamp>) until the server returns the real one. form.reset()clears the input immediately -- the user can start typing the next entry while the server catches up.- Do not rely on optimistic UI for destructive actions -- always wait for server confirmation before showing a deletion as final.
Related: Optimistic Forms -- rollback patterns, error UX | useOptimistic (hooks) -- hook API | useOptimistic (React 19) -- the React 19 primitive
11. File Upload with Drag-and-Drop
Accept files via click or drop, preview them, and validate size/type with Zod.
"use client";
import { useState } from "react";
import { z } from "zod";
const FileSchema = z
.instanceof(File)
.refine((f) => f.size < 2 * 1024 * 1024, "Max 2MB")
.refine((f) => f.type.startsWith("image/"), "Images only");
export default function ImageUploader() {
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFile = (file: File) => {
const result = FileSchema.safeParse(file);
if (!result.success) {
setError(result.error.issues[0].message);
return;
}
setError(null);
setPreview(URL.createObjectURL(file));
};
return (
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}}
style={{ display: "block", border: "2px dashed #999", padding: "2rem" }}
>
Drop an image or click to browse
<input
type="file"
hidden
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
{preview && <img src={preview} alt="preview" style={{ maxWidth: 200 }} />}
{error && <p role="alert">{error}</p>}
</label>
);
}z.instanceof(File).refine(...)lets you apply Zod's fluent API to browserFileobjects.URL.createObjectURL(file)gives a local preview URL -- callURL.revokeObjectURLon unmount to avoid leaks in long sessions.onDragOvermust calle.preventDefault()or the browser rejects the drop entirely.- For the upload itself, send the file through a Server Action or a pre-signed upload URL -- never POST multi-megabyte files directly to your API layer unthrottled.
Related: File Upload Patterns -- drag-drop, multiple files, progress, S3 | Drag & Drop Events -- the underlying event API
12. Multi-Step Form with useReducer
Manage wizard state -- fields, step index, validation -- with explicit reducer actions.
"use client";
import { useReducer } from "react";
interface State {
step: number;
data: { name: string; email: string; plan: string };
}
type Action =
| { type: "set"; field: keyof State["data"]; value: string }
| { type: "next" }
| { type: "back" }
| { type: "reset" };
const initial: State = { step: 0, data: { name: "", email: "", plan: "" } };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "set":
return { ...state, data: { ...state.data, [action.field]: action.value } };
case "next":
return { ...state, step: state.step + 1 };
case "back":
return { ...state, step: Math.max(0, state.step - 1) };
case "reset":
return initial;
}
}
export default function Wizard() {
const [state, dispatch] = useReducer(reducer, initial);
return (
<div>
{state.step === 0 && (
<input
placeholder="Name"
value={state.data.name}
onChange={(e) => dispatch({ type: "set", field: "name", value: e.target.value })}
/>
)}
{state.step === 1 && (
<input
placeholder="Email"
value={state.data.email}
onChange={(e) => dispatch({ type: "set", field: "email", value: e.target.value })}
/>
)}
{state.step === 2 && <p>Review: {JSON.stringify(state.data)}</p>}
<button onClick={() => dispatch({ type: "back" })} disabled={state.step === 0}>
Back
</button>
<button onClick={() => dispatch({ type: "next" })} disabled={state.step === 2}>
Next
</button>
</div>
);
}- The reducer is a pure function -- same state + action = same result, which makes it easy to unit-test.
- Typing actions as a discriminated union gives you autocompletion and catches invalid dispatches at compile time.
- Per-step validation belongs in the reducer or in a transition guard before
dispatch({ type: "next" }). - For production wizards, combine this with react-hook-form per step and a Zod schema per step for the validation.
Related: useReducer Multi-Step Forms -- testing, validation guards, persistence | Form Patterns Complex -- wizards, field arrays, conditional fields | useReducer -- the underlying hook
13. Complete Validated Form (Login)
Ties it all together: Zod schema, RHF, accessible errors, typed submit handler.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const LoginSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
});
type LoginValues = z.infer<typeof LoginSchema>;
export default function LoginForm() {
const {
register, handleSubmit, formState: { errors, isSubmitting },
} = useForm<LoginValues>({ resolver: zodResolver(LoginSchema) });
const onSubmit = async (values: LoginValues) => {
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(values),
});
if (!res.ok) alert("Login failed");
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input
id="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-err" : undefined}
{...register("email")}
/>
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-invalid={!!errors.password}
aria-describedby={errors.password ? "pw-err" : undefined}
{...register("password")}
/>
{errors.password && <p id="pw-err" role="alert">{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</form>
);
}noValidateon the<form>disables browser HTML5 messages so your Zod/RHF errors are the only source of truth.isSubmittingcomes from RHF'sformState-- use it to disable the submit button and prevent double-submits.- Pair each
aria-describedbywith a matchingidon the error element so screen readers link them. - Upgrade path: swap the
fetchcall for a Server Action and switch touseActionStatewhen you want progressive enhancement.
Related: Form Patterns Basic -- more ready-to-use form templates | Form Accessibility -- ARIA patterns in depth | Decision Checklist -- picking the right approach for a given form