Forms
Handle user input with controlled components, uncontrolled refs, or React 19 form actions.
Search across all documentation pages
Handle user input with controlled components, uncontrolled refs, or React 19 form actions.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
value + onChange for each inputuseActionState (React 19) managing form submission state, pending indicator, and validationFormData API for reading form values without controlled statelabel + htmlFor pairingvalue on every renderFormDataasync function to <form action={fn}>. React handles the submission, provides isPending, and integrates with useActionState for managing return valuesuseActionState(actionFn, initialState) returns [state, wrappedAction, isPending] - it calls actionFn(prevState, formData) on submit and updates state with the result| 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 |
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 |
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>
);
}// 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
}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 strings - e.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 form - useFormStatus 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.
| 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 |
value + onChange - React is the source of truthdefaultValue - read it on demand with a ref or FormDataYou 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.
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.
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.
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.
Use checked / defaultChecked instead of value / defaultValue:
<input
type="checkbox"
checked={isEnabled}
onChange={e => setIsEnabled(e.target.checked)}
/>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).
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.
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 }));
}Use useOptimistic to immediately show the expected result while the action processes. If the action fails, React reverts to the actual state automatically.
<label htmlFor="id"> matching the input's idrequired, aria-describedby for error messages, and aria-invalid for invalid fieldsReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥