React 19 made async mutations a first-class concept. A function passed to a form's action prop is an Action: React tracks its pending state, handles errors, and resets the form on success.
A form with useActionState
'use client';
import { useActionState } from 'react';
type State = { error?: string; ok?: boolean };
async function subscribe(prev: State, formData: FormData): Promise<State> {
const email = String(formData.get('email') ?? '');
const res = await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify({ email }),
});
return res.ok ? { ok: true } : { error: 'Could not subscribe' };
}
export function SubscribeForm() {
const [state, action, pending] = useActionState(subscribe, {});
return (
<form action={action}>
<input name="email" type="email" required />
<button disabled={pending}>{pending ? 'Sending…' : 'Subscribe'}</button>
{state.error && <p role="alert">{state.error}</p>}
{state.ok && <p>Thanks!</p>}
</form>
);
}
No useState for loading and error, no manual preventDefault.
useFormStatus for nested components
A submit button deep in the tree can read the parent form's status:
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
}
Optimistic updates with useOptimistic
Show the result immediately and let React roll back if the request fails.
'use client';
import { useOptimistic } from 'react';
export function Comments({ comments, send }: Props) {
const [optimistic, addOptimistic] = useOptimistic(
comments,
(current, text: string) => [...current, { id: 'tmp', text, sending: true }],
);
async function action(formData: FormData) {
const text = String(formData.get('text'));
addOptimistic(text);
await send(text);
}
return (
<>
<ul>
{optimistic.map((c) => (
<li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>{c.text}</li>
))}
</ul>
<form action={action}>
<input name="text" />
</form>
</>
);
}
Server Actions in Next.js
In Next.js you can pass a Server Action to action directly and the form even works before JavaScript loads:
async function createPost(formData: FormData) {
'use server';
await db.post.create({ data: { title: String(formData.get('title')) } });
}
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<button>Create</button>
</form>
);
}
Always validate input on the server. A Server Action is a public endpoint.