On this page

Controlled Forms: Validation and React 19 Actions

14 min read TextCh. 4 — Data and Forms

Forms in React: Controlled vs. Uncontrolled

React offers two approaches to form handling:

  • Controlled inputs: React state drives the value. You get instant access to the value for validation, transformation, and conditional logic.
  • Uncontrolled inputs: The DOM stores the value. You access it via a ref on submit.

Modern React applications use controlled inputs for the majority of forms because they give you complete control and enable real-time validation.

The Controlled Input Pattern

A controlled input keeps its value in React state and updates state on every change:

import { useState, type ChangeEvent } from 'react';

function NameInput() {
  const [name, setName] = useState('');
  const isValid = name.trim().length >= 2;

  return (
    <div>
      <label htmlFor="name">Your name</label>
      <input
        id="name"
        type="text"
        value={name}
        onChange={(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
        aria-invalid={!isValid && name.length > 0}
      />
      {!isValid && name.length > 0 && (
        <span role="alert">Name must be at least 2 characters.</span>
      )}
    </div>
  );
}

Building a Multi-Field Form

A practical approach for forms with multiple fields uses a single state object and a shared handleChange handler:

import { useState, type ChangeEvent, type FormEvent } from 'react';

interface ContactFormData {
  subject: string;
  message: string;
  category: string;
  urgent: boolean;
}

const INITIAL: ContactFormData = {
  subject: '',
  message: '',
  category: 'general',
  urgent: false,
};

function ContactForm() {
  const [formData, setFormData] = useState<ContactFormData>(INITIAL);
  const [isSubmitting, setIsSubmitting] = useState(false);

  function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) {
    const { name, value, type } = e.target;
    const checked = type === 'checkbox' ? (e.target as HTMLInputElement).checked : undefined;

    setFormData((prev) => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
  }

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });
      setFormData(INITIAL); // reset form
    } finally {
      setIsSubmitting(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="subject">Subject</label>
        <input
          id="subject"
          name="subject"
          type="text"
          value={formData.subject}
          onChange={handleChange}
          required
        />
      </div>

      <div>
        <label htmlFor="category">Category</label>
        <select
          id="category"
          name="category"
          value={formData.category}
          onChange={handleChange}
        >
          <option value="general">General inquiry</option>
          <option value="billing">Billing</option>
          <option value="support">Technical support</option>
        </select>
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          value={formData.message}
          onChange={handleChange}
          rows={5}
          required
        />
      </div>

      <div>
        <input
          id="urgent"
          name="urgent"
          type="checkbox"
          checked={formData.urgent}
          onChange={handleChange}
        />
        <label htmlFor="urgent">Mark as urgent</label>
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send message'}
      </button>
    </form>
  );
}

Validation Patterns

Validate on Submit

Validate the entire form only when the user submits. Good for simpler forms:

function validate(data: ContactFormData): string[] {
  const errors: string[] = [];
  if (!data.subject.trim()) errors.push('Subject is required');
  if (data.message.trim().length < 20) errors.push('Message must be at least 20 characters');
  return errors;
}

Validate on Blur (field-level)

Validate a field when the user leaves it:

const [touched, setTouched] = useState<Set<string>>(new Set());

function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
  setTouched((prev) => new Set(prev).add(e.target.name));
}

// Show error only if the field was touched
const showSubjectError = touched.has('subject') && !formData.subject.trim();

React 19 Form Actions with useActionState

React 19 introduces useActionState — a hook that pairs with async action functions attached to <form action={...}>:

import { useActionState } from 'react';

type LoginState = {
  errors: { email?: string; password?: string };
  message: string | null;
};

async function loginAction(
  _prev: LoginState,
  formData: FormData
): Promise<LoginState> {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  if (!email.includes('@')) {
    return { errors: { email: 'Invalid email' }, message: null };
  }

  if (password.length < 6) {
    return { errors: { password: 'Password too short' }, message: null };
  }

  // Simulate API call
  await new Promise((r) => setTimeout(r, 1000));

  return { errors: {}, message: 'Logged in successfully!' };
}

function LoginForm() {
  const [state, formAction, isPending] = useActionState(loginAction, {
    errors: {},
    message: null,
  });

  return (
    <form action={formAction}>
      <div>
        <label htmlFor="login-email">Email</label>
        <input
          id="login-email"
          name="email"
          type="email"
          required
          aria-invalid={!!state.errors.email}
          aria-describedby={state.errors.email ? 'email-err' : undefined}
        />
        {state.errors.email && (
          <span id="email-err" role="alert">{state.errors.email}</span>
        )}
      </div>

      <div>
        <label htmlFor="login-password">Password</label>
        <input
          id="login-password"
          name="password"
          type="password"
          required
          aria-invalid={!!state.errors.password}
        />
        {state.errors.password && (
          <span role="alert">{state.errors.password}</span>
        )}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Logging in...' : 'Log in'}
      </button>

      {state.message && <p role="status">{state.message}</p>}
    </form>
  );
}

The advantage of this pattern is that it works progressively — in frameworks like Next.js with the App Router, the same form action can run on the server even without JavaScript enabled.

useOptimistic for Instant Feedback

React 19 also introduces useOptimistic for displaying optimistic state while an action is pending:

import { useOptimistic, useActionState } from 'react';

interface Comment {
  id: number;
  text: string;
  pending?: boolean;
}

function CommentSection({ initialComments }: { initialComments: Comment[] }) {
  const [comments, setComments] = useState(initialComments);
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (state, newComment: Comment) => [...state, newComment]
  );

  async function handleAddComment(formData: FormData) {
    const text = formData.get('text') as string;
    const tempComment: Comment = { id: -1, text, pending: true };

    addOptimistic(tempComment); // instantly show in UI

    const saved = await fetch('/api/comments', {
      method: 'POST',
      body: JSON.stringify({ text }),
      headers: { 'Content-Type': 'application/json' },
    }).then((r) => r.json() as Promise<Comment>);

    setComments((prev) => [...prev.filter((c) => c.id !== -1), saved]);
  }

  return (
    <div>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.pending ? 0.6 : 1 }}>
            {c.text} {c.pending && '(saving...)'}
          </li>
        ))}
      </ul>
      <form action={handleAddComment}>
        <input name="text" type="text" placeholder="Add a comment..." required />
        <button type="submit">Post</button>
      </form>
    </div>
  );
}

In the next lesson, you will learn about professional data fetching patterns with TanStack Query — caching, invalidation, mutations, and pagination.

Use useActionState for React 19 form actions
React 19 introduces `useActionState` (previously `useFormState`) which pairs perfectly with async form actions. It gives you the current state, the action to pass to `<form action={...}>`, and a `isPending` boolean for loading states — all without manual `useState` calls.
Use the native constraint validation API as a first layer
Before writing custom validation logic, leverage HTML's built-in validation attributes: `required`, `type='email'`, `minLength`, `pattern`, `min`, `max`. Combine with `noValidate` on the form to control the browser's default validation UI while still having access to the `validity` object.
Always associate labels with inputs
Every form input must have an associated `<label>` using the `htmlFor`/`id` pair. Without it, screen readers cannot announce what the input is for. For error messages, use `aria-describedby` pointing to the error's `id` and `aria-invalid` to signal the invalid state.
import { useState, type ChangeEvent, type FormEvent } from 'react';

interface RegisterFormData {
  name: string;
  email: string;
  password: string;
  confirmPassword: string;
}

type FormErrors = Partial<Record<keyof RegisterFormData, string>>;

function validate(data: RegisterFormData): FormErrors {
  const errors: FormErrors = {};

  if (!data.name.trim()) {
    errors.name = 'Name is required';
  }

  if (!data.email.includes('@')) {
    errors.email = 'Enter a valid email address';
  }

  if (data.password.length < 8) {
    errors.password = 'Password must be at least 8 characters';
  }

  if (data.password !== data.confirmPassword) {
    errors.confirmPassword = 'Passwords do not match';
  }

  return errors;
}

function RegisterForm() {
  const [formData, setFormData] = useState<RegisterFormData>({
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
  });
  const [errors, setErrors] = useState<FormErrors>({});
  const [submitted, setSubmitted] = useState(false);

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
    // Clear error on change
    if (errors[name as keyof RegisterFormData]) {
      setErrors((prev) => ({ ...prev, [name]: undefined }));
    }
  }

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const validationErrors = validate(formData);
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    await new Promise((r) => setTimeout(r, 500)); // simulate API
    setSubmitted(true);
  }

  if (submitted) {
    return <p role="status">Registration successful! Welcome, {formData.name}.</p>;
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div className="field">
        <label htmlFor="name">Full name</label>
        <input
          id="name"
          name="name"
          type="text"
          value={formData.name}
          onChange={handleChange}
          aria-describedby={errors.name ? 'name-error' : undefined}
          aria-invalid={!!errors.name}
          autoComplete="name"
        />
        {errors.name && (
          <span id="name-error" role="alert" className="error">
            {errors.name}
          </span>
        )}
      </div>

      <div className="field">
        <label htmlFor="email">Email address</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          aria-describedby={errors.email ? 'email-error' : undefined}
          aria-invalid={!!errors.email}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" role="alert" className="error">
            {errors.email}
          </span>
        )}
      </div>

      <button type="submit">Create account</button>
    </form>
  );
}

export default RegisterForm;