React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

formscontrolleduncontrolledactionsreact-19

Forms

Handle user input with controlled components, uncontrolled refs, or React 19 form actions.

Recipe

Quick-reference recipe card — copy-paste ready.

// Controlled input
const [email, setEmail] = useState("");
<input value={email} onChange={e => setEmail(e.target.value)} />
 
// Uncontrolled input with ref
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} defaultValue="" />
// Read later: inputRef.current?.value
 
// React 19 form action
async function createUser(formData: FormData) {
  const name = formData.get("name") as string;
  await saveToDatabase(name);
}
<form action={createUser}>
  <input name="name" required />
  <button type="submit">Create</button>
</form>
 
// React 19 useActionState
const [state, formAction, isPending] = useActionState(submitFn, initialState);

When to reach for this: Any time you collect user input — login forms, search bars, settings pages, multi-step wizards.

Working Example

"use client";
 
import { useState, useActionState } from "react";
 
// --- Controlled Form ---
 
export function ControlledSignup() {
  const [form, setForm] = useState({ name: "", email: "", role: "viewer" });
  const [submitted, setSubmitted] = useState(false);
 
  function updateField(field: string, value: string) {
    setForm(prev => ({ ...prev, [field]: value }));
  }
 
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setSubmitted(true);
  }
 
  if (submitted) {
    return (
      <div className="rounded bg-green-50 p-4 text-green-800">
        Welcome, {form.name}! We sent a confirmation to {form.email}.
      </div>
    );
  }
 
  return (
    <form onSubmit={handleSubmit} className="max-w-sm space-y-3 rounded border p-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">Name</label>
        <input
          id="name"
          value={form.name}
          onChange={e => updateField("name", e.target.value)}
          required
          className="w-full rounded border px-3 py-1"
        />
      </div>
      <div>
        <label htmlFor="email" className="block text-sm font-medium">Email</label>
        <input
          id="email"
          type="email"
          value={form.email}
          onChange={e => updateField("email", e.target.value)}
          required
          className="w-full rounded border px-3 py-1"
        />
      </div>
      <div>
        <label htmlFor="role" className="block text-sm font-medium">Role</label>
        <select
          id="role"
          value={form.role}
          onChange={e => updateField("role", e.target.value)}
          className="w-full rounded border px-3 py-1"
        >
          <option value="viewer">Viewer</option>
          <option value="editor">Editor</option>
          <option value="admin">Admin</option>
        </select>
      </div>
      <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
        Sign Up
      </button>
    </form>
  );
}
 
// --- React 19 Form Action with useActionState ---
 
interface FormState {
  message: string;
  error: boolean;
}
 
async function submitFeedback(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const feedback = formData.get("feedback") as string;
 
  // Simulate server delay
  await new Promise(resolve => setTimeout(resolve, 1000));
 
  if (feedback.length < 10) {
    return { message: "Feedback must be at least 10 characters.", error: true };
  }
 
  return { message: `Thanks for your feedback!`, error: false };
}
 
export function FeedbackForm() {
  const [state, formAction, isPending] = useActionState(submitFeedback, {
    message: "",
    error: false,
  });
 
  return (
    <form action={formAction} className="max-w-sm space-y-3 rounded border p-4">
      <label htmlFor="feedback" className="block text-sm font-medium">
        Your Feedback
      </label>
      <textarea
        id="feedback"
        name="feedback"
        required
        rows={3}
        className="w-full rounded border px-3 py-1"
        placeholder="Tell us what you think..."
      />
 
      {state.message && (
        <p className={state.error ? "text-sm text-red-600" : "text-sm text-green-600"}>
          {state.message}
        </p>
      )}
 
      <button
        type="submit"
        disabled={isPending}
        className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isPending ? "Submitting..." : "Send Feedback"}
      </button>
    </form>
  );
}

What this demonstrates:

  • Controlled form with value + onChange for each input
  • useActionState (React 19) managing form submission state, pending indicator, and validation
  • FormData API for reading form values without controlled state
  • Accessible label + htmlFor pairing
  • Disabled submit button during async action

Deep Dive

How It Works

  • Controlled components keep input values in React state — the component is the source of truth, and the input reflects value on every render
  • Uncontrolled components let the DOM hold the value — you read it on demand via a ref or FormData
  • React 19 form actions let you pass an async function to <form action={fn}>. React handles the submission, provides isPending, and integrates with useActionState for managing return values
  • useActionState(actionFn, initialState) returns [state, wrappedAction, isPending] — it calls actionFn(prevState, formData) on submit and updates state with the result

Controlled vs Uncontrolled vs Actions

ApproachSource of TruthBest For
Controlled (value + onChange)React stateReal-time validation, conditional fields, complex interdependent inputs
Uncontrolled (defaultValue + ref)DOMSimple forms, third-party integrations, performance-sensitive inputs
Form Actions (React 19)FormDataServer-side mutations, progressive enhancement, reducing client state

Parameters & Return Values

useActionState:

ParameterTypeDescription
action(prevState: T, formData: FormData) => T or Promise<T>Function called on form submission
initialStateTInitial state before first submission
permalink?stringOptional URL for progressive enhancement with SSR
ReturnTypeDescription
stateTCurrent state (updated after action completes)
formAction(formData: FormData) => voidWrapped action to pass to <form action>
isPendingbooleantrue while the action is running

Variations

Uncontrolled with FormData (no useState needed):

function SearchForm() {
  function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const query = formData.get("query") as string;
    router.push(`/search?q=${encodeURIComponent(query)}`);
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="query" defaultValue="" />
      <button type="submit">Search</button>
    </form>
  );
}

useFormStatus for nested submit buttons:

import { useFormStatus } from "react-dom";
 
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Saving..." : "Save"}
    </button>
  );
}
 
// Usage — SubmitButton must be a child of a <form>
<form action={saveAction}>
  <input name="title" />
  <SubmitButton />
</form>

Optimistic updates with useOptimistic:

import { useOptimistic } from "react";
 
function MessageList({ messages, sendAction }: Props) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, newMessage: string) => [
      ...state,
      { id: "temp", text: newMessage, sending: true },
    ]
  );
 
  async function handleSubmit(formData: FormData) {
    const text = formData.get("message") as string;
    addOptimistic(text);
    await sendAction(formData);
  }
 
  return (
    <form action={handleSubmit}>
      <ul>
        {optimisticMessages.map(msg => (
          <li key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}>
            {msg.text}
          </li>
        ))}
      </ul>
      <input name="message" />
      <button type="submit">Send</button>
    </form>
  );
}

TypeScript Notes

// Typing onChange for different input types
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { ... }
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) { ... }
function handleTextareaChange(e: React.ChangeEvent<HTMLTextAreaElement>) { ... }
 
// Typing form action state
interface ActionState {
  success: boolean;
  errors: Record<string, string>;
}
 
async function myAction(
  prev: ActionState,
  formData: FormData
): Promise<ActionState> {
  // validate and return new state
}

Gotchas

  • Missing value or onChange — Setting value without onChange makes the input read-only. React warns about this. Fix: Either add an onChange handler or use defaultValue for uncontrolled inputs.

  • defaultValue doesn't update — Changing defaultValue after mount has no effect because it only sets the initial DOM value. Fix: Use controlled value if you need React to drive updates, or add a key to remount the input.

  • Checkbox and radio checked — These use checked / defaultChecked, not value / defaultValue. Fix: <input type="checkbox" checked={isOn} onChange={e => setIsOn(e.target.checked)} />.

  • Number inputs return stringse.target.value is always a string, even for <input type="number">. Fix: Parse explicitly: Number(e.target.value) or parseInt(e.target.value, 10).

  • useFormStatus outside a formuseFormStatus only works when the component is rendered as a descendant of a <form>. Calling it in the same component that renders the <form> returns stale data. Fix: Extract the submit button into a child component.

Alternatives

AlternativeUse WhenDon't Use When
React Hook FormComplex validation, many fields, performance-critical formsSimple forms with 1-3 fields
Zod + Server ActionsSchema-validated server mutations in Next.jsClient-only forms with no server
FormikLegacy projects already using itNew projects (React Hook Form is lighter)
Native <dialog> + <form method="dialog">Modal confirmation dialogsMulti-field data collection

FAQs

What is the difference between controlled and uncontrolled inputs?
  • Controlled: React state drives the input via value + onChange — React is the source of truth
  • Uncontrolled: The DOM holds the value via defaultValue — read it on demand with a ref or FormData
  • Use controlled for real-time validation; uncontrolled for simple forms or performance-sensitive inputs
What are React 19 form actions?

You can pass an async function directly to <form action={fn}>. React calls it with a FormData object on submit, handles the pending state, and integrates with useActionState for managing return values — no e.preventDefault() needed.

What is useActionState and how does it work?

useActionState(actionFn, initialState) returns [state, formAction, isPending]. On submit, it calls actionFn(prevState, formData) and updates state with the result. isPending is true while the action runs.

Why is my controlled input read-only?

Setting value without an onChange handler makes the input uneditable — React locks the value to state. Either add onChange to update state, or switch to defaultValue for an uncontrolled input.

Why doesn't changing defaultValue update my input?

defaultValue only sets the initial DOM value on mount. Changes after mount have no effect. Use controlled value if React needs to drive updates, or add a key prop to force remount.

How do I handle checkboxes and radio buttons?

Use checked / defaultChecked instead of value / defaultValue:

<input
  type="checkbox"
  checked={isEnabled}
  onChange={e => setIsEnabled(e.target.checked)}
/>
How do I get a number from a number input?

e.target.value is always a string, even for <input type="number">. Parse it explicitly: Number(e.target.value) or parseInt(e.target.value, 10).

What is useFormStatus and where can I use it?

useFormStatus() returns { pending } to show loading state during a form action. It must be called in a component that is a child of a <form> — not in the same component that renders the form.

How do I handle multiple form fields without separate useState for each?

Use a single state object and a generic update function:

const [form, setForm] = useState({ name: "", email: "" });
function updateField(field: string, value: string) {
  setForm(prev => ({ ...prev, [field]: value }));
}
When should I use a form library like React Hook Form instead?
  • More than 3-5 fields with validation rules
  • Complex interdependent validation (field B depends on field A)
  • Performance-critical forms where re-renders per keystroke matter
  • For simple forms with 1-3 fields, plain controlled inputs or form actions are sufficient
How do I implement optimistic updates with forms in React 19?

Use useOptimistic to immediately show the expected result while the action processes. If the action fails, React reverts to the actual state automatically.

How do I make forms accessible?
  • Pair every input with a <label htmlFor="id"> matching the input's id
  • Use required, aria-describedby for error messages, and aria-invalid for invalid fields
  • Ensure error messages are announced to screen readers
  • Events — general event handling that forms build on
  • Refs — accessing uncontrolled input values
  • useState — powering controlled inputs
  • Components — composing form field components