Form Actions & useActionState
React 19 introduces a modern, declarative model for handling form submissions called Actions. Instead of wiring up an onSubmit handler, calling preventDefault(), and juggling useState flags for pending and error states, you pass a function directly to a form’s action prop. React then manages the submission lifecycle for you and exposes purpose-built hooks — useActionState and useFormStatus — to read pending status and results. This approach reduces boilerplate, works with progressive enhancement, and integrates cleanly with server-side mutations.
The form action prop
In React 19, the <form> element accepts a function for its action prop. When the form is submitted, React calls that function with a FormData object containing the form fields. React automatically prevents the default browser submission and resets the form on success.
function NewsletterForm() {
async function subscribe(formData) {
const email = formData.get("email");
await fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify({ email }),
headers: { "Content-Type": "application/json" },
});
}
return (
<form action={subscribe}>
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
Because the action receives FormData, you read values by name rather than tracking each input with controlled state. This keeps simple forms uncontrolled and lean.
Action functions can be synchronous or asynchronous. If the function returns a promise, React treats the form as pending until it resolves, which powers the hooks below.
Tracking state with useActionState
useActionState wraps an action so you can read the latest result and a pending flag. It is the cornerstone hook for forms that need to show validation messages, success states, or errors.
The hook signature is useActionState(actionFn, initialState). It returns a tuple: the current state, a wrapped action to pass to <form action>, and an isPending boolean.
import { useActionState } from "react";
async function login(previousState, formData) {
const email = formData.get("email");
const password = formData.get("password");
const res = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, password }),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
return { error: "Invalid credentials", email };
}
return { success: true };
}
function LoginForm() {
const [state, formAction, isPending] = useActionState(login, {});
return (
<form action={formAction}>
<input name="email" type="email" defaultValue={state.email} required />
<input name="password" type="password" required />
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="ok">Welcome back!</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
Note that the action’s first argument is the previous state and the second is the FormData. The value you return becomes the next state. Returning the submitted email lets you repopulate the field after a failed attempt — a clean way to preserve user input.
Nested submit buttons with useFormStatus
When a submit button lives in a separate component (a shared <SubmitButton>, for example), it does not have access to the parent’s isPending value. The useFormStatus hook solves this: it reads the status of the nearest parent form without prop drilling.
import { useFormStatus } from "react-dom";
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting…" : children}
</button>
);
}
function ContactForm({ action }) {
return (
<form action={action}>
<textarea name="message" required />
<SubmitButton>Send message</SubmitButton>
</form>
);
}
useFormStatusis imported fromreact-dom, notreact, and must be called from a component rendered inside a<form>. Calling it in the same component that renders the<form>returnspending: false.
The hook returns several fields:
| Field | Type | Description |
|---|---|---|
pending | boolean | Whether the parent form is currently submitting |
data | FormData | null | The data being submitted |
method | string | null | The HTTP method (get or post) |
action | function | string | null | The action handler or URL |
Progressive enhancement and server actions
Because actions are built on native form semantics, they degrade gracefully. If you pass a string URL to action, the form performs a standard browser POST when JavaScript has not yet loaded, then upgrades to the client-side action once React hydrates.
In React Server Components frameworks (such as Next.js with the App Router), an action can be a Server Action marked with the "use server" directive. The function runs on the server, so you can query a database directly inside it.
"use server";
import { db } from "@/lib/db";
export async function createTodo(previousState, formData) {
const title = formData.get("title");
if (!title) return { error: "Title is required" };
await db.todo.create({ data: { title } });
return { success: true };
}
This server action can be passed to useActionState in a client component exactly like a local function, giving you the same pending and result handling while the mutation runs securely on the server.
Best practices
- Prefer the
actionprop over manualonSubmithandlers for new forms — let React manage pending and reset behavior. - Read field values from the
FormDataargument instead of mirroring every input in controlled state. - Use
useActionStatewhen you need to surface errors, success messages, or repopulate fields after a failed submission. - Reach for
useFormStatusto build reusable submit buttons and spinners that stay in sync with their parent form. - Always disable the submit button while
pendingto prevent duplicate submissions. - Pass a string URL or a Server Action to keep forms functional before hydration for genuine progressive enhancement.
- Remember
useFormStatuscomes fromreact-domand only works inside a descendant of the<form>.