Every form used to come with the same boilerplate: an isSubmitting flag, an error state, a preventDefault, a try/catch and a reset. React 19 turns that pattern into a first-class feature called Actions, and a small set of hooks handles pending state, results and optimistic UI for you.
Forms can take a function
In React 19, a form's action prop can be a function, including an async one. React calls it with the form's FormData when the form submits, tracks it as a transition, and resets uncontrolled fields when it succeeds. No onSubmit, no preventDefault.
useActionState: results and pending in one hook
useActionState(action, initialState) wraps your action and returns [state, formAction, isPending]. Your action receives the previous state and the form data, and whatever it returns becomes the new state. That makes it a natural home for validation errors and success messages.
type State = { error?: string; ok?: boolean };
async function subscribe(prev: State, formData: FormData): Promise<State> {
const email = String(formData.get('email') ?? '');
if (!email.includes('@')) return { error: 'Enter a valid email' };
const res = await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify({ email }),
});
return res.ok ? { ok: true } : { error: 'Something went wrong' };
}
export function Newsletter() {
const [state, formAction, isPending] = useActionState(subscribe, {});
return (
<form action={formAction}>
<input name="email" type="email" />
<button disabled={isPending}>{isPending ? 'Joining…' : 'Join'}</button>
{state.error && <p role="alert">{state.error}</p>}
{state.ok && <p>You are in.</p>}
</form>
);
}useFormStatus for reusable buttons
useFormStatus, imported from react-dom, returns { pending, data, method, action } for the form it is rendered inside. The catch: it must be called from a component that is a child of the form, not from the component that renders the form. That is exactly what makes it useful for a shared submit button.
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Saving…' : children}
</button>
);
}Optimistic UI with useOptimistic
useOptimistic(state, updateFn) returns [optimisticState, addOptimistic]. While an action is running, you call addOptimistic to show the expected result immediately. When the action finishes, React discards the optimistic value and renders the real state. If the request failed and you never updated the real state, the optimistic item simply disappears: rollback comes for free.
type Comment = { id: string; text: string; sending?: boolean };
export function Comments({ initial }: { initial: Comment[] }) {
const [comments, setComments] = useState(initial);
const [error, setError] = useState<string | null>(null);
const [optimistic, addOptimistic] = useOptimistic(
comments,
(current: Comment[], text: string) => [
...current,
{ id: 'temp', text, sending: true },
],
);
async function post(formData: FormData) {
const text = String(formData.get('text') ?? '');
setError(null);
addOptimistic(text);
try {
const saved = await createComment(text);
setComments((c) => [...c, saved]);
} catch {
setError('Could not post your comment. Try again.');
}
}
return (
<>
<ul>
{optimistic.map((c) => (
<li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>{c.text}</li>
))}
</ul>
<form action={post}>
<input name="text" required />
<SubmitButton>Post</SubmitButton>
</form>
{error && <p role="alert">{error}</p>}
</>
);
}Server Actions and progressive enhancement
In Next.js, the action can be a Server Action: an async function marked with 'use server' that runs on the server. Pass it to useActionState or directly to a form's action. When that form is rendered by a Server Component, it can submit before the JavaScript bundle has loaded, so the form still works on a slow connection, and the hooks take over once the page hydrates.
Key takeaways
- —Pass functions, including async ones, to a form's
actionprop. - —
useActionStategives you the returned state, a wrapped action andisPending. - —
useFormStatusfromreact-dommust be called inside a child of the form. - —
useOptimisticshows the expected result instantly and rolls back automatically. - —Use
startTransitionfor actions that are not forms. - —Server Actions let forms work before hydration in Next.js.
The result is less state to manage and better UX by default: forms that show progress, report errors and feel instant, without a single isSubmitting flag.
