React Forms and Validation — Complete Guide
In this tutorial, you'll learn about React Forms and Validation. We cover key concepts, practical examples, and best practices.
What You'll Learn
Build production-ready React forms with controlled and uncontrolled inputs, schema-based validation with Zod, file uploads, multi-step workflows, and accessibility patterns using React Hook Form and TypeScript.
Why It Matters
Forms are the primary way users input data in web applications. Poorly built forms cause validation frustration, accessibility barriers, and data loss. React's declarative model makes forms predictable, but choosing the right library and patterns determines whether your forms are maintainable or a nightmare of boilerplate.
Real-World Use
A multi-step checkout form with field-level validation, credit card formatting, address autocomplete, file upload for receipts, and a submission handler that collects all data into a single payload — used by an e-commerce platform processing thousands of orders daily.
Learning Path
flowchart LR
A[React Basics] --> B[Hooks Deep Dive]
B --> C[Forms and Validation]
C --> D[API Integration]
D --> E[State Management]
E --> F[Final Project]
C -->|You are here| C
Controlled vs Uncontrolled Inputs
Controlled inputs store value in React state. Uncontrolled inputs use the DOM via refs. Choose based on your needs.
import { useState, useRef } from "react";
// Controlled input — state drives the value
function ControlledForm() {
const [email, setEmail] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
console.log("Controlled email:", email);
}
return (
<form onSubmit={handleSubmit}>
<label>
Email (controlled)
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<button type="submit">Submit</button>
<p>Preview: {email}</p>
</form>
);
}
// Uncontrolled input — DOM stores the value
function UncontrolledForm() {
const emailRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
console.log("Uncontrolled email:", emailRef.current?.value);
}
return (
<form onSubmit={handleSubmit}>
<label>
Email (uncontrolled)
<input type="email" ref={emailRef} defaultValue="" />
</label>
<button type="submit">Submit</button>
</form>
);
}
Expected output: In ControlledForm, the preview updates on every keystroke. The state is the single source of truth. In UncontrolledForm, the value is read from the DOM only on submit. Use controlled inputs when you need real-time validation, conditional fields, or formatting. Use uncontrolled inputs for simple, performant forms.
React Hook Form with Zod Validation
React Hook Form minimizes re-renders and boilerplate. Paired with Zod, validation becomes declarative and type-safe.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const signupSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(18, "Must be at least 18").max(120),
password: z.string().min(8, "Minimum 8 characters"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});
type SignupFormData = z.infer<typeof signupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
});
async function onSubmit(data: SignupFormData) {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" {...register("name")} />
{errors.name && <span role="alert">{errors.name.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <span role="alert">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input id="age" type="number" {...register("age")} />
{errors.age && <span role="alert">{errors.age.message}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <span role="alert">{errors.password.message}</span>}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input id="confirmPassword" type="password" {...register("confirmPassword")} />
{errors.confirmPassword && <span role="alert">{errors.confirmPassword.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing up..." : "Sign Up"}
</button>
</form>
);
}
Expected output: The form validates on submit. Empty fields show "Name must be at least 2 characters", "Invalid email address", "Minimum 8 characters", etc. Mismatched passwords show "Passwords must match". The submit button is disabled during submission. The inferred SignupFormData TypeScript type matches the schema exactly.
Multi-Step Form Wizard
Multi-step forms improve conversion by reducing cognitive load per step.
import { useState } from "react";
import { useForm, useFormContext, FormProvider } from "react-hook-form";
type CheckoutData = {
shipping: { address: string; city: string; zip: string };
payment: { cardNumber: string; expiry: string; cvv: string };
review: { agreeToTerms: boolean };
};
function ShippingStep() {
const { register, formState: { errors } } = useFormContext<CheckoutData>();
return (
<fieldset>
<legend>Shipping Address</legend>
<label>Street
<input {...register("shipping.address", { required: true })} />
</label>
<label>City
<input {...register("shipping.city", { required: true })} />
</label>
<label>ZIP Code
<input {...register("shipping.zip", { required: true })} />
</label>
</fieldset>
);
}
function PaymentStep() {
const { register, formState: { errors } } = useFormContext<CheckoutData>();
return (
<fieldset>
<legend>Payment Details</legend>
<label>Card Number
<input {...register("payment.cardNumber", { required: true })} />
</label>
<label>Expiry
<input placeholder="MM/YY" {...register("payment.expiry", { required: true })} />
</label>
<label>CVV
<input type="password" maxLength={4} {...register("payment.cvv", { required: true })} />
</label>
</fieldset>
);
}
function ReviewStep() {
const { getValues, register } = useFormContext<CheckoutData>();
const data = getValues();
return (
<div>
<h3>Review Your Order</h3>
<p>Shipping: {data.shipping.address}, {data.shipping.city}</p>
<p>Card ending in {data.payment.cardNumber.slice(-4)}</p>
<label>
<input type="checkbox" {...register("review.agreeToTerms", { required: true })} />
I agree to the terms and conditions
</label>
</div>
);
}
function CheckoutForm() {
const methods = useForm<CheckoutData>({ mode: "onBlur" });
const [step, setStep] = useState(0);
const steps = [ShippingStep, PaymentStep, ReviewStep];
const StepComponent = steps[step];
async function onSubmit(data: CheckoutData) {
await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify(data),
});
}
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<StepComponent />
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
{step > 0 && <button type="button" onClick={() => setStep((s) => s - 1)}>Back</button>}
{step < steps.length - 1 ? (
<button type="button" onClick={() => setStep((s) => s + 1)}>Next</button>
) : (
<button type="submit">Place Order</button>
)}
</div>
</form>
</FormProvider>
);
}
Expected output: The multi-step form shows shipping fields first, then payment, then review. Each step validates before advancing. The final step shows a summary of all entered data before submission. The entire form state is available at the top level for the final submit.
File Upload with Drag and Drop
import { useState, useRef, DragEvent, ChangeEvent } from "react";
interface UploadedFile {
name: string;
size: number;
preview: string;
}
function FileUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
function processFiles(fileList: FileList) {
const newFiles: UploadedFile[] = Array.from(fileList).map((f) => ({
name: f.name,
size: f.size,
preview: URL.createObjectURL(f),
}));
setFiles((prev) => [...prev, ...newFiles]);
}
function handleDrop(e: DragEvent) {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files) processFiles(e.dataTransfer.files);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
if (e.target.files) processFiles(e.target.files);
}
return (
<div>
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${isDragging ? "#007bff" : "#ccc"}`,
padding: "40px",
textAlign: "center",
cursor: "pointer",
background: isDragging ? "#f0f7ff" : "transparent",
}}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleChange}
hidden
/>
<p>Drop files here or click to browse</p>
</div>
<ul>
{files.map((f, i) => (
<li key={i}>
<img src={f.preview} alt={f.name} width={64} height={64} />
{f.name} ({(f.size / 1024).toFixed(1)} KB)
</li>
))}
</ul>
</div>
);
}
Expected output: A dashed drop zone area. Dragging files highlights the border blue. Dropped files appear below with thumbnail preview and size. Clicking the zone opens the file browser. Multiple files can be uploaded at once.
Accessibility Patterns
Every form element must have an accessible label. Use <label htmlFor="id">, aria-describedby for hints, and role="alert" for errors.
function AccessibleField({
label,
error,
hint,
children,
}: {
label: string;
error?: string;
hint?: string;
children: React.ReactNode;
}) {
const id = label.toLowerCase().replace(/\s+/g, "-");
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const describedBy = [error ? errorId : "", hint ? hintId : ""].filter(Boolean).join(" ");
return (
<div>
<label htmlFor={id}>{label}</label>
{hint && <p id={hintId}>{hint}</p>}
{React.cloneElement(children as React.ReactElement, {
id,
"aria-describedby": describedBy || undefined,
"aria-invalid": error ? true : undefined,
})}
{error && <span id={errorId} role="alert">{error}</span>}
</div>
);
}
Common Mistakes
1. Forgetting noValidate on Forms
Browsers show native validation bubbles that conflict with custom validation UI. Always add noValidate to the form element when using JavaScript validation.
2. Not Handling Loading States
Submitting a form that takes 2 seconds without disabling the button allows double submission. Disable the submit button during submission and show a spinner.
3. Storing Derivative State
Computing error messages or formatted values in state instead of deriving them during render. Use useMemo for derived values instead of syncing state.
Practice Questions
Challenge
Build a registration form with five fields, real-time validation using Zod, a password strength indicator, conditional fields (show "company name" only when "account type" is "business"), and a submit handler that prevents double submission and shows a success toast.
Real-World Task
Build a multi-step onboarding form for a SaaS product: Step 1 collects personal info, Step 2 collects preferences with conditional fields, Step 3 is a summary. Use React Hook Form with FormProvider. On submit, send the complete data to an API. Add a "Save as Draft" button that persists to localStorage.
This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications where forms handle millions of user submissions with validation, accessibility, and reliability.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro