Forms & Validation Basics
13 examples to get you started with Forms & Validation -- 8 basic and 5 intermediate.
Search across all documentation pages
13 examples to get you started with Forms & Validation -- 8 basic and 5 intermediate.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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:
"use client"). Native <form action={serverAction}> forms can stay server-side.value in React state; uncontrolled inputs read via FormData or ref.Choosing an approach? See the Decision Checklist to pick the right pattern for your form before writing code.
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>
);
}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.onSubmit for <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
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>
);
}value is state, onChange updates state -- React owns the input.value={state} -- never defaultValue -- 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
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);
}safeParse returns { success, data | error } -- use it when you want to handle errors without try/catch..email(), .min(), .max()) each add a rule and attach the error message.parse instead 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
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.input gives you the input type (before transforms); z.output gives the output type (after transforms).Related: Zod Infer --
z.infervsz.inputvsz.output, transform pitfalls | Typing API Responses -- using schemas at fetch boundaries
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.handleSubmit runs validation first; your callback only fires with clean, typed data.Controller instead of register.Related: React Hook Form -- register, Controller, watch, reset | Form Patterns Basic -- login, signup, contact recipes
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 in errors.<field>.message automatically.z.coerce.number() turns the string from <input> into a number, saving you a manual parse step.Related: RHF + Zod -- deep dive, defaults, dynamic fields | shadcn Form -- same stack with accessible shadcn UI components
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>
);
}Object.fromEntries(formData) turns FormData into a plain object Zod can parse.useActionState returns [state, action, isPending] -- wire action directly into the form.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
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-invalid announces that the input's current value is rejected.aria-describedby links 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.onBlur or 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
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>
);
}Form is a thin wrapper that threads RHF context through its subcomponents -- all the ARIA plumbing is automatic.FormMessage renders the current field's error message without any manual errors.<field>.message lookup.npx shadcn@latest add form input button scaffolds the components.Related: shadcn Form -- full shadcn form recipe | shadcn Form (component) -- the UI primitives | RHF + Zod -- the stack underneath
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.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.Related: Optimistic Forms -- rollback patterns, error UX | useOptimistic (hooks) -- hook API | useOptimistic (React 19) -- the React 19 primitive
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 browser File objects.URL.createObjectURL(file) gives a local preview URL -- call URL.revokeObjectURL on unmount to avoid leaks in long sessions.onDragOver must call e.preventDefault() or the browser rejects the drop entirely.Related: File Upload Patterns -- drag-drop, multiple files, progress, S3 | Drag & Drop Events -- the underlying event API
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>
);
}dispatch({ type: "next" }).Related: useReducer Multi-Step Forms -- testing, validation guards, persistence | Form Patterns Complex -- wizards, field arrays, conditional fields | useReducer -- the underlying hook
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>
);
}noValidate on the <form> disables browser HTML5 messages so your Zod/RHF errors are the only source of truth.isSubmitting comes from RHF's formState -- use it to disable the submit button and prevent double-submits.aria-describedby with a matching id on the error element so screen readers link them.fetch call for a Server Action and switch to useActionState when 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
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥