Actions are React 19's built-in answer to pending states, errors, optimistic updates and form resets that every app hand-rolled before.
An Action is an async function run inside a transition. React tracks its pending state, and updates made by it are batched and applied when it finishes. You get Actions in several ways.
Form actions. Pass a function to <form action={fn}> (or <button formAction={fn}>). React calls it with the form's FormData on submit, without a page reload, and resets uncontrolled fields when it succeeds.
`useActionState(action, initialState)` returns [state, formAction, isPending]. Your action receives (previousState, formData) and returns the next state, typically a result or error message:
const [error, submit, isPending] = useActionState(async (prev, formData) => {
const res = await updateName(formData.get("name"));
return res.error ?? null;
}, null);
<form action={submit}>...</form>Submissions are queued in order, and the state is the last returned value.
`useFormStatus()` (from react-dom) lets a component inside a form read { pending, data, method, action } of the enclosing form's submission. It's perfect for a reusable <SubmitButton> that disables itself, with no prop drilling. It must be called in a child of the <form>, not the component that renders it.
`useOptimistic(state, updateFn)` shows a temporary value while an Action runs, like a sent message appearing immediately. When the Action finishes, the optimistic value is discarded and the real state is shown; if it failed, the UI simply reverts.
You can also start an Action from any handler with startTransition(async () => {...}). Validate input in the action, return errors as state rather than throwing for expected failures, and remember that with a framework the action can be a Server Function, so the same form works before JavaScript loads.
Example
import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";
async function saveTodo(text) {
await new Promise(r => setTimeout(r, 800));
if (text.length < 3) throw new Error("Too short");
return { id: crypto.randomUUID(), text };
}
function SubmitButton() {
const { pending } = useFormStatus(); // reads the parent <form>
return <button disabled={pending}>{pending ? "Saving..." : "Add"}</button>;
}
export default function Todos() {
const [state, formAction] = useActionState(async (prev, formData) => {
const text = formData.get("text");
addOptimistic(text);
try {
return { todos: [...prev.todos, await saveTodo(text)], error: null };
} catch (e) {
return { ...prev, error: e.message };
}
}, { todos: [], error: null });
const [optimisticTodos, addOptimistic] = useOptimistic(
state.todos,
(todos, text) => [...todos, { id: "pending", text: text + " (saving)" }]
);
return (
<form action={formAction}>
<input name="text" />
<SubmitButton />
{state.error && <p role="alert">{state.error}</p>}
<ul>{optimisticTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
</form>
);
}Remember
<form action={fn}>callsfn(formData)in a transition and resets the form on successuseActionStategives[state, action, isPending]; the action gets(prevState, formData)useFormStatusreads the parent form's pending state;useOptimisticshows temporary UI
Watch out for
- Calling
useFormStatusin the same component that renders the<form>; it only reports on a parent form.