Actions & useActionState
Form actions. Pending state. Server actions overview.
React 19 made a quiet but huge change: <form>got first-class superpowers. You can now pass a function to the form'saction prop, and React handles submission, FormData, pending state, and even progressive enhancement for you. Combined withuseActionState and useFormStatus, it's the cleanest form pattern React has ever shipped.
The old way (still works)
function ContactForm() {
const [name, setName] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setPending(true);
try { await saveContact({ name }); }
catch (err) { setError(err.message); }
finally { setPending(false); }
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button disabled={pending}>{pending ? "..." : "Save"}</button>
{error && <p>{error}</p>}
</form>
);
}Three pieces of state for one form. Multiply by every field. Multiply by every form in your app. React 19 trims this down dramatically.
The new way: form action props
function ContactForm() {
async function save(formData) {
const name = formData.get("name");
await saveContact({ name });
}
return (
<form action={save}>
<input name="name" />
<button type="submit">Save</button>
</form>
);
}Pass a function to action. React intercepts submit, calls your function with the FormData, and resets the form on success. Notice: no useState for the inputs, no onSubmit, no preventDefault. The form does the work.
action is a real HTML form attribute, the form works without JavaScript: it does a regular browser POST. In Next.js with server actions, the same code submits via a network request to the server and re-renders. The user without JS still gets a working form.useActionState: state, error, pending in one hook
For forms that need to display a result (success message, server error, validation), useActionState is the bigger gun:
import { useActionState } from "react";
async function submitAction(prevState, formData) {
const name = formData.get("name");
if (!name) return { error: "Name required" };
try {
await saveContact({ name });
return { success: true, name };
} catch (e) {
return { error: e.message };
}
}
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitAction, null);
return (
<form action={formAction}>
<input name="name" />
<button disabled={isPending}>{isPending ? "Saving..." : "Save"}</button>
{state?.error && <p style={{ color: "red" }}>{state.error}</p>}
{state?.success && <p>Saved {state.name}!</p>}
</form>
);
}The action signature is (prevState, formData) => nextState. Whatever you return becomes the new state. isPending flips to true while the action runs and back when it resolves. One hook, all three concerns.
useFormStatus: pending state from descendants
Sometimes a button or a spinner deep inside the form needs to know if the form is submitting, but it doesn't own the form's state. useFormStatusreads the nearest parent form's status.
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</button>
);
}
// Usage, SubmitButton doesn't need any props
function Form() {
return (
<form action={save}>
<input name="title" />
<SubmitButton />
</form>
);
}This is the key to reusable submit buttons. SubmitButton doesn't know or care which form it's in. It just reads the ambient form status.
FormData: the underused web standard
React 19 actions work natively with FormData, a long existing browser API. Every <input name="..."> contributes a field. You read them out with formData.get:
async function save(formData) {
const name = formData.get("name"); // string
const email = formData.get("email");
const file = formData.get("avatar"); // File object for <input type="file">
const tags = formData.getAll("tags"); // array for multiple inputs
const isAdmin = formData.has("admin"); // checkbox presence
await api.save({ name, email, tags, isAdmin });
}Validation lives wherever you want. Use Zod, do it by hand, run it on the server. The point is: the form is just data, and the data is just FormData.
action={fn}still works for client-side forms. But the "works without JS" + "runs on the server" superpowers come from the framework treating your "use server" functions as endpoints. We cover that in the Next.js chapter.Try it: a form with state and pending
Three actions running through one form. Try invalid inputs to see errors flow through the returned state. Try the "throw" button to see error handling.
Quiz
What does passing a function to <form action={fn}> do?
Recap
<form action={fn}>turns a function into a submit handler that gets FormData.useActionStatereturns[state, formAction, isPending]. Use it when you need a typed result back from the action.useFormStatusexposes the parent form's pending state to descendants. Reusable submit buttons.- Forms work as native HTML forms when JS is disabled.
- You write less code. The form is just data. The browser is your ally.