Forms in React · Lesson 15 of 31
React 19 Form Actions
React 19 added actions: functions that run when a form is submitted, possibly asynchronously. React tracks their pending state, collects their result and resets the form for you. Together with three new hooks – useActionState, useFormStatus and useOptimistic – they remove most of the boilerplate around submitting data. In Next.js the same API powers Server Actions.
In this lesson
- Passing a function to
<form action> useActionStatefor results, errors and pending stateuseFormStatusfor submit buttonsuseOptimisticfor instant UI updates
A form action
import { useState } from "react";
export default function App() {
const [names, setNames] = useState(["Asha"]);
function addName(formData) { // receives FormData, no event, no preventDefault
setNames((n) => [...n, formData.get("name")]);
}
return (
<form action={addName}>
<label>Name <input name="name" /></label>
<button>Add</button>
<p>Attendees: {names.join(", ")}</p>
</form>
);
}
Name [] [Add] Attendees: Asha › type “Ravi” into Name Name [Ravi] [Add] Attendees: Asha › click “Add” Name [] [Add] Attendees: Asha, Ravi
When you pass a function to action, React calls it with the form's FormData, prevents the page reload, and resets the form's uncontrolled fields after the action finishes – notice the input is empty again.
useActionState: results, errors and pending
import { useActionState } from "react";
// pretend API call
async function subscribe(previousState, formData) {
await new Promise((r) => setTimeout(r, 300));
const email = String(formData.get("email") ?? "").trim();
if (!email.includes("@")) return { error: "Enter a valid email", email }; // keep the typed value
return { message: `Subscribed ${email}` };
}
export default function App() {
const [state, formAction, isPending] = useActionState(subscribe, {}); // (action, initial state)
return (
<form action={formAction}>
<label>Email <input name="email" defaultValue={state.email} key={state.email} /></label>
<button disabled={isPending}>{isPending ? "Subscribing…" : "Subscribe"}</button>
{state.error && <p role="alert">{state.error}</p>}
{state.message && <p>✔ {state.message}</p>}
</form>
);
}
Email [] [Subscribe] › type “asha” into Email Email [asha] [Subscribe] › click “Subscribe” Email [asha] [Subscribing… (disabled)] › wait 350ms Email [asha] [Subscribe] Enter a valid email › clear Email and type “asha@example.com” Email [asha@example.com] [Subscribe] Enter a valid email › click “Subscribe” Email [asha@example.com] [Subscribing… (disabled)] Enter a valid email › wait 350ms Email [] [Subscribe] ✔ Subscribed asha@example.com
- The action receives the previous state and the
FormData; whatever it returns becomes the newstate. isPendingistruewhile the action runs – no manualsetLoading.- Because the form resets after an action, the example returns the typed email on error and uses it as
defaultValue(thekeymakes React apply the new default).
useFormStatus: a reusable submit button
import { useFormStatus } from "react-dom";
import { useActionState } from "react";
function SubmitButton({ children }) {
const { pending } = useFormStatus(); // status of the parent <form>
return <button disabled={pending}>{pending ? "Please wait…" : children}</button>;
}
async function saveProfile(prev, formData) {
await new Promise((r) => setTimeout(r, 200));
return `Saved bio (${String(formData.get("bio")).length} characters)`;
}
export default function App() {
const [message, formAction] = useActionState(saveProfile, "");
return (
<form action={formAction}>
<textarea name="bio" aria-label="Bio" defaultValue="Frontend developer" />
<SubmitButton>Save profile</SubmitButton>
{message && <p>{message}</p>}
</form>
);
}
[Frontend developer] [Save profile] › click “Save profile” [Frontend developer] [Please wait… (disabled)] › wait 250ms [Frontend developer] [Save profile] Saved bio (18 characters)
useFormStatus must be called from a component rendered inside the form – that's why the button is its own component. Every form in your app can now reuse one SubmitButton.
useOptimistic: update the UI before the server answers
import { useOptimistic, useState } from "react";
async function sendToServer(text) {
await new Promise((r) => setTimeout(r, 400));
return text;
}
export default function App() {
const [messages, setMessages] = useState(["Hi! Ready for the React quiz?"]);
const [optimistic, addOptimistic] = useOptimistic(messages, (current, text) => [...current, text + " (sending…)"]);
async function send(formData) {
const text = formData.get("message");
addOptimistic(text); // shows immediately
const saved = await sendToServer(text);
setMessages((m) => [...m, saved]); // real state replaces the optimistic one
}
return (
<form action={send}>
<ul>{optimistic.map((m, i) => <li key={i}>{m}</li>)}</ul>
<input name="message" aria-label="Message" />
<button>Send</button>
</form>
);
}
• Hi! Ready for the React quiz? [] [Send] › type “Yes, let's go!” into Message • Hi! Ready for the React quiz? [Yes, let's go!] [Send] › click “Send” • Hi! Ready for the React quiz? • Yes, let's go! (sending…) [Yes, let's go!] [Send] › wait 450ms • Hi! Ready for the React quiz? • Yes, let's go! [] [Send]
While the action runs, React shows the optimistic list. When it finishes, the optimistic value is dropped and the real messages state is shown. If the request fails, the optimistic item simply disappears – like a chat app showing "not sent".
Controlled forms or actions?
- Actions – submit-and-save forms: sign-ups, comments, settings. Less state, built-in pending, and they work with Next.js Server Actions.
- Controlled inputs – when the UI must react to every keystroke: live search, formatting, dependent fields.
- You can mix them: a controlled field inside a form that uses an action.
Practice
Try it yourself
Build a coupon form with
useActionState: "LEARN20" gives "20% off applied", anything else shows "Invalid coupon".Show solution
import { useActionState } from "react"; async function applyCoupon(prev, formData) { await new Promise((r) => setTimeout(r, 150)); const code = String(formData.get("code")).trim().toUpperCase(); return code === "LEARN20" ? "20% off applied" : "Invalid coupon"; } export default function App() { const [result, formAction, isPending] = useActionState(applyCoupon, ""); return ( <form action={formAction}> <label>Coupon <input name="code" /></label> <button disabled={isPending}>Apply</button> {result && <p>{result}</p>} </form> ); }Coupon [] [Apply] › type “save10” into Coupon Coupon [save10] [Apply] › click “Apply” Coupon [save10] [Apply (disabled)] › wait 200ms Coupon [] [Apply] Invalid coupon › type “learn20” into Coupon Coupon [learn20] [Apply] Invalid coupon › click “Apply” Coupon [learn20] [Apply (disabled)] Invalid coupon › wait 200ms Coupon [] [Apply] 20% off applied
Quick check
What does a form action function receive?
The form's FormData (and with useActionState, the previous state first).
Where must useFormStatus be called?
In a component rendered inside the form.
What happens to an optimistic value when the action finishes?
It's discarded and the real state is shown.
Summary
- <form action={fn}> calls fn with FormData, without a reload, and resets the form afterwards.
- useActionState returns [state, formAction, isPending]; useFormStatus gives a nested button the pending flag.
- useOptimistic shows the expected result instantly while the async action runs.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.