Skip to content

React Forms and Validation — Complete Guide

DodaTech 8 min read

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

When should I use controlled vs uncontrolled inputs?

Use controlled inputs when you need real-time validation, conditional field rendering, or input formatting (credit card, phone number). Use uncontrolled inputs for simple forms with few fields where performance or simplicity matters more than real-time feedback.

What is the advantage of React Hook Form over managing form state manually?

React Hook Form reduces re-renders by isolating input updates, provides declarative validation with resolvers (Zod, Yup), handles form submission and error state, and works well with complex patterns like multi-step forms and dynamic fields.

How do I handle file uploads in React?

Use an uncontrolled input with type="file" and a ref to access the FileList on submit. For drag-and-drop, listen to onDrop and read dataTransfer.files. Upload files using FormData with fetch or axios.

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