Form Actions & useActionState
In the previous lesson you handled forms with onSubmit, e.preventDefault() and several pieces of state for "saving", "error" and "done". React 19 added Actions, which remove most of that boilerplate. You pass a function, even an async one, straight to a form's action prop, and React handles the pending state, errors and form reset for you.
In this lesson you will write form actions, show a pending button with useFormStatus, keep the result of each submission with useActionState, and make the interface feel instant with useOptimistic.
A function as the form action
In plain HTML, <form action="/subscribe"> sends the form to a URL. In React 19 you can pass a function instead. React calls it with a FormData object when the form is submitted. You do not need preventDefault(), and after an action finishes successfully React resets the form's uncontrolled fields automatically.
import { createRoot } from "react-dom/client";
import { useFormStatus } from "react-dom";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Subscribing..." : "Subscribe"}</button>;
}
function Newsletter() {
async function subscribe(formData) {
const email = formData.get("email");
await wait(1000); // pretend to call the server
console.log(`Subscribed ${email} to the Lucknow food newsletter`);
}
return (
<form action={subscribe}>
<input name="email" type="email" placeholder="you@example.com" required />
<SubmitButton />
</form>
);
}
createRoot(document.getElementById("root")).render(<Newsletter />);Entering "rohan@example.com" and pressing "Subscribe" disables the button for one second, shows "Subscribing...", clears the input, and prints:
Subscribed rohan@example.com to the Lucknow food newsletter
React runs the action inside a transition, a lower-priority update that keeps the page responsive while the async work runs. The useFormStatus hook, imported from react-dom, reads the status of the form it is inside: pending is true while the action runs, and data holds the submitted FormData. It must be called in a component rendered inside the <form>; that is why the button is its own component.
useActionState: remember the result
Most forms need to show something after submitting: a success message, a validation error or a new total. useActionState connects an action to a piece of state.
const [state, formAction, isPending] = useActionState(action, initialState);- Your
action(previousState, formData)receives the previous state first and the form data second, and whatever it returns becomes the newstate. formActionis the function you pass to<form action={...}>.isPendingistruewhile the action is running.
import { useActionState } from "react";
import { createRoot } from "react-dom/client";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const coupons = { DIWALI10: 10, STUDENT15: 15 };
async function applyCoupon(previous, formData) {
const code = String(formData.get("code")).trim().toUpperCase();
await wait(700); // pretend to check with the server
if (!(code in coupons)) {
return { ...previous, error: `"${code}" is not a valid coupon.` };
}
const discount = coupons[code];
return { total: Math.round(previous.base * (1 - discount / 100)), base: previous.base, error: "", applied: code };
}
function Checkout() {
const [state, formAction, isPending] = useActionState(applyCoupon, {
base: 2400,
total: 2400,
error: "",
applied: "",
});
return (
<form action={formAction}>
<p>Cricket kit total: ₹{state.total.toLocaleString("en-IN")}</p>
{state.applied && <p style={{ color: "green" }}>{state.applied} applied</p>}
<input name="code" placeholder="Coupon code" />
<button disabled={isPending}>{isPending ? "Checking..." : "Apply"}</button>
{state.error && <p style={{ color: "crimson" }}>{state.error}</p>}
</form>
);
}
createRoot(document.getElementById("root")).render(<Checkout />);Try "HOLI50" first, then "student15". The wrong code shows an error; the correct one changes the total to ₹2,040. Notice that expected problems, such as a bad coupon, are returned as part of the state rather than thrown. If an action throws, React treats it as an unexpected error and shows the nearest error boundary instead.
useActionState runs submissions one after another: if the user clicks twice quickly, the second call receives the result of the first as previous. When you call the returned formAction yourself instead of passing it to a form, wrap the call in startTransition from react so React can track it.
useOptimistic: show the result before the server answers
Some actions almost always succeed, such as liking a post or sending a chat message. Waiting a second for the server makes the app feel slow. An optimistic update shows the expected result immediately and corrects it if the server fails.
useOptimistic(value, reducer) returns [optimisticValue, addOptimistic]. While an action is running, optimisticValue includes your temporary changes; when the action ends, it goes back to the real value.
import { useState, useOptimistic, startTransition } from "react";
import { createRoot } from "react-dom/client";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function saveMessage(text) {
await wait(1200);
if (text.toLowerCase().includes("fail")) throw new Error("Network error, message not sent");
return { id: crypto.randomUUID(), text };
}
function ClassGroupChat() {
const [messages, setMessages] = useState([{ id: "m1", text: "Maths test moved to Friday" }]);
const [error, setError] = useState("");
const [optimisticMessages, addOptimistic] = useOptimistic(messages, (current, text) => [
...current,
{ id: "temp", text, sending: true },
]);
async function sendAction(formData) {
const text = formData.get("text");
setError("");
addOptimistic(text); // appears immediately
try {
const saved = await saveMessage(text);
startTransition(() => setMessages((m) => [...m, saved]));
} catch (err) {
setError(err.message); // the optimistic message disappears when the action ends
}
}
return (
<>
<ul>
{optimisticMessages.map((m) => (
<li key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>
{m.text} {m.sending && <small>(sending...)</small>}
</li>
))}
</ul>
<form action={sendAction}>
<input name="text" placeholder="Type a message" required />
<button>Send</button>
</form>
{error && <p style={{ color: "crimson" }}>{error}</p>}
</>
);
}
createRoot(document.getElementById("root")).render(<ClassGroupChat />);Send "See you at 9" and it appears at once in grey with "(sending...)", then turns solid. Send a message containing the word "fail" and it appears, then vanishes with an error, because the real messages never received it. The setter call after await is wrapped in startTransition so React applies it together with the end of the action, rather than as a separate update.
addOptimistic must be called inside an action or transition. Called anywhere else, React logs an error and the optimistic value disappears straight away.
Actions on the server
In frameworks such as Next.js, an action can run on the server. A function marked with the "use server" directive becomes a Server Function that the form calls over the network, while you still write <form action={createOrder}>:
// app/actions.js (Next.js)
"use server";
export async function createOrder(formData) {
const item = formData.get("item");
// save to the database here; this code never reaches the browser
}You will meet this again in Server Components & Next.js.
Common mistakes
- Calling
useFormStatusin the form component itself. It only sees a parent<form>; put it in a child such asSubmitButton. - Wrong argument order. With
useActionState, the action receives(previousState, formData), not(formData). - Throwing for validation errors. Return error messages as state; throwing triggers an error boundary.
- Expecting controlled inputs to reset. Automatic reset applies to uncontrolled fields; reset your own state for controlled ones.
- Optimistic updates outside an action. Call
addOptimisticinside the form action or astartTransitioncallback.
Key points
- In React 19 a
<form action={fn}>callsfn(formData), can beasync, and resets uncontrolled fields on success. useFormStatus()fromreact-domgivespendinganddatato components inside the form.useActionState(action, initial)returns[state, formAction, isPending]; the action gets(previousState, formData).- Return expected errors in state; thrown errors go to error boundaries.
useOptimisticshows a temporary result during an action and falls back to the real value afterwards.
Exercise
Build a "Book a demo class" form with fields name and course (a select with "Python", "React" and "SQL"). Use useActionState with an async action that waits 800 ms, rejects names shorter than 3 characters by returning an error, and otherwise returns a confirmation such as "Seat booked for Priya in React". Show the confirmation or the error, and disable the button while pending.
Show solution
import { useActionState } from "react";
import { createRoot } from "react-dom/client";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function book(previous, formData) {
const name = String(formData.get("name")).trim();
const course = formData.get("course");
await wait(800);
if (name.length < 3) {
return { ok: false, message: "Please enter at least 3 letters of your name." };
}
return { ok: true, message: `Seat booked for ${name} in ${course}` };
}
function DemoBooking() {
const [result, formAction, isPending] = useActionState(book, null);
return (
<form action={formAction}>
<input name="name" placeholder="Your name" />
<select name="course" defaultValue="React">
<option>Python</option>
<option>React</option>
<option>SQL</option>
</select>
<button disabled={isPending}>{isPending ? "Booking..." : "Book"}</button>
{result && <p style={{ color: result.ok ? "green" : "crimson" }}>{result.message}</p>}
</form>
);
}
createRoot(document.getElementById("root")).render(<DemoBooking />);The action returns a result object in both cases, so the component simply renders whatever the latest state is. isPending disables the button during the wait.
Quick Quiz
1. What does a function passed to <form action={...}> receive in React 19?
React calls the action with the form's data as FormData.
2. Where must useFormStatus be called?
It reads the status of a parent form, so it must be inside one.
3. With useActionState, what are the action's arguments?
The previous state comes first, then the payload (the form data for a form action).
4. What happens to an optimistic value when its action fails?
Optimistic state lasts only while the action runs; afterwards React shows the real value.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
