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+onChangefor each input useActionState(React 19) managing form submission state, pending indicator, and validationFormDataAPI for reading form values without controlled state- Accessible
label+htmlForpairing - 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
valueon 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
asyncfunction to<form action={fn}>. React handles the submission, providesisPending, and integrates withuseActionStatefor managing return values useActionState(actionFn, initialState)returns[state, wrappedAction, isPending]— it callsactionFn(prevState, formData)on submit and updates state with the result
Controlled vs Uncontrolled vs Actions
| Approach | Source of Truth | Best For |
|---|---|---|
Controlled (value + onChange) | React state | Real-time validation, conditional fields, complex interdependent inputs |
Uncontrolled (defaultValue + ref) | DOM | Simple forms, third-party integrations, performance-sensitive inputs |
| Form Actions (React 19) | FormData | Server-side mutations, progressive enhancement, reducing client state |
Parameters & Return Values
useActionState:
| Parameter | Type | Description |
|---|---|---|
action | (prevState: T, formData: FormData) => T or Promise<T> | Function called on form submission |
initialState | T | Initial state before first submission |
permalink? | string | Optional URL for progressive enhancement with SSR |
| Return | Type | Description |
|---|---|---|
state | T | Current state (updated after action completes) |
formAction | (formData: FormData) => void | Wrapped action to pass to <form action> |
isPending | boolean | true 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
valueoronChange— SettingvaluewithoutonChangemakes the input read-only. React warns about this. Fix: Either add anonChangehandler or usedefaultValuefor uncontrolled inputs. -
defaultValuedoesn't update — ChangingdefaultValueafter mount has no effect because it only sets the initial DOM value. Fix: Use controlledvalueif you need React to drive updates, or add akeyto remount the input. -
Checkbox and radio
checked— These usechecked/defaultChecked, notvalue/defaultValue. Fix:<input type="checkbox" checked={isOn} onChange={e => setIsOn(e.target.checked)} />. -
Number inputs return strings —
e.target.valueis always a string, even for<input type="number">. Fix: Parse explicitly:Number(e.target.value)orparseInt(e.target.value, 10). -
useFormStatus outside a form —
useFormStatusonly 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
| Alternative | Use When | Don't Use When |
|---|---|---|
| React Hook Form | Complex validation, many fields, performance-critical forms | Simple forms with 1-3 fields |
| Zod + Server Actions | Schema-validated server mutations in Next.js | Client-only forms with no server |
| Formik | Legacy projects already using it | New projects (React Hook Form is lighter) |
Native <dialog> + <form method="dialog"> | Modal confirmation dialogs | Multi-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 orFormData - 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'sid - Use
required,aria-describedbyfor error messages, andaria-invalidfor invalid fields - Ensure error messages are announced to screen readers
Related
- Events — general event handling that forms build on
- Refs — accessing uncontrolled input values
- useState — powering controlled inputs
- Components — composing form field components