React SME Cookbook
All FAQs
basicsforms-validationexampleszod

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/resolvers

Conventions used throughout:

  1. Validation schemas are Zod objects -- single source of truth for runtime checks and compile-time types.
  2. Interactive forms are Client Components ("use client"). Native <form action={serverAction}> forms can stay server-side.
  3. Controlled inputs keep their 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.


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


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 = value is state, onChange updates 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} -- 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


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);
}
  • safeParse returns { success, data | error } -- use it when you want to handle errors without try/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 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


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.input gives you the input type (before transforms); z.output gives 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.infer vs z.input vs z.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.
  • handleSubmit runs validation first; your callback only fires with clean, typed data.
  • For rich inputs (date pickers, selects from UI libraries), wrap them in RHF's Controller instead of register.

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 in errors.<field>.message automatically.
  • 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) turns FormData into a plain object Zod can parse.
  • useActionState returns [state, action, isPending] -- wire action directly 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-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.
  • Defer errors until 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


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 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.
  • 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 button scaffolds 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 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.
  • 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>
  );
}
  • 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.
  • Pair each aria-describedby with a matching id on the error element so screen readers link them.
  • Upgrade path: swap the 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