Form actions
Use React 19 form actions: pass a function to a form's action, show the answer and the wait with useActionState and useFormStatus, and control resets.
- 01Hand a form a functionPass a function to a form's
action, read theFormDataReact gives it, and droppreventDefault. - 02Show the answer and the waitKeep an action's result and its pending flag with
useActionState, and build a reusable submit button withuseFormStatus. - 03Know when the form emptiesSay when React resets a form, keep what people typed after an error, and reset by hand with
requestFormReset.
A form that calls a function
In Forms and inputs, every form that sent anything had an onSubmit handler that stopped the page from loading, built a FormData, and read it. React 19 can do that part for you. Give the form a function as its action, and React calls it with the form's data. Type a tea in the result and press Add:
import { useState } from "react"; export default function App() { const [order, setOrder] = useState(["Chai"]); function addTea(formData) { setOrder([...order, formData.get("tea")]); } return ( <form action={addTea}> <label> Tea <input name="tea" required /> </label> <button>Add</button> <p>Your order: {order.join(", ")}</p> </form> );}Line 11 passes addTea to the form's action, where plain HTML would have a web address. When you press Add, React calls addTea (lines 6 to 8) with a FormData holding every named field, so line 7 reads the tea with formData.get("tea") and adds it to the order. Type Mint and press Add: line 16 says Your order: Chai, Mint, and the box is empty again.
There's no e.preventDefault(), no new FormData, and no line that empties the box. React did all three:
FormData from the form, and calls addTea(formData). When addTea finishes, React resets the form, so the Tea box is empty again.A function passed to a form this way is called an action. It can be async, it can wait for a server, and React keeps track of it while it runs. That tracking is what the rest of this lesson builds on: showing the server's answer, showing that the form is busy, and knowing when React empties the form.
At a parcel counter, you don't walk the parcel to the van yourself. You hand over a filled-in slip, the clerk does the sending while a light says Busy, and you get a receipt and a blank slip for next time.
- In real life: You hand your filled-in slip over the counter
- In React: You press the submit button
- In real life: The clerk takes the slip, not your whole bag
- In React: React builds a FormData from the form's named fields
- In real life: The clerk sends the parcel while a light says Busy
- In React: Your action runs, and React marks the form as pending
- In real life: You get a receipt and a fresh blank slip
- In React: React shows the new state and resets the form
A form action is a function you pass to a form's action prop. When the form is submitted, React stops the page from loading, calls your function with the form's FormData, tracks it as pending while it runs, and resets the form's uncontrolled fields when it finishes. useActionState keeps its result, and useFormStatus tells a component inside the form whether it's busy.
What React does when the form is submitted
Here is the same order form written the way the last lesson did, with onSubmit. Type Mint and press Add:
import { useState } from "react"; export default function App() { const [order, setOrder] = useState(["Chai"]); function handleSubmit(e) { e.preventDefault(); const formData = new FormData(e.currentTarget); setOrder([...order, formData.get("tea")]); } return ( <form onSubmit={handleSubmit}> <label> Tea <input name="tea" required /> </label> <button>Add</button> <p>Your order: {order.join(", ")}</p> </form> );}The order grows the same way, but Mint stays in the box: nothing here resets the form. Lines 7 and 8 are the two jobs the action version left to React. Both ways work in React 19, and onSubmit with preventDefault is what you'll see in most code written before it.
How to read the table: each row asks one question about sending a form, answered for each way.
| Question | onSubmit | A function in action |
|---|---|---|
| Who stops the page from loading? | You, with e.preventDefault(). | React, for you. |
| Who builds the FormData? | You, with new FormData(e.currentTarget). | React, and it passes it to your function. |
| Can the function wait for a server? | Yes, but React doesn't know you're waiting. | Yes, and React tracks the wait as pending. |
| What happens to the fields afterwards? | Nothing. They keep their text. | React resets the uncontrolled ones when the function finishes. |
| Does it work with useActionState and useFormStatus? | No. | Yes. |
React runs an action inside a Transition: a group of updates React may take its time over, and which it marks as pending until it finishes. You don't call anything to start one; a function in action gets one for free. The Transitions lesson covers them in depth. The form actions, useActionState, and useFormStatus in this lesson all arrived in React 19, the version these examples run (19.3.1); the React 19 entry in React's changelog lists them.
A second button with its own action
A button can override the form's action with formAction. Then each button does its own thing with the same fields. Type 3 in the box, press Add to cart, then Save for later:
import { useState } from "react"; export default function App() { const [cart, setCart] = useState(0); const [saved, setSaved] = useState(0); function addToCart(formData) { setCart(cart + Number(formData.get("packs"))); } function saveForLater(formData) { setSaved(saved + Number(formData.get("packs"))); } return ( <form action={addToCart}> <label> Packs of chai <input name="packs" type="number" min="1" defaultValue="1" /> </label> <button>Add to cart</button> <button formAction={saveForLater}>Save for later</button> <p>In cart: {cart}. Saved for later: {saved}.</p> </form> );}Line 16 makes addToCart the form's action, and line 21 gives the Save for later button formAction={saveForLater}. Line 20's button has none, so it uses the form's. After Add to cart with 3, line 22 says In cart: 3. Saved for later: 0., and the box is back to 1, its defaultValue. Save for later adds the 1: Saved for later: 1. Both functions are actions, so both reset the form when they finish. Don't give a button with a function formAction a name; React needs that spot for itself and warns.
useActionState: the server's answer and the wait
Real forms send their data to a server and wait. Here the server is pretend: the api.js tab answers after one second, and says no to an email that's already on the list. An action can simply await it. Type mint@example.com and press Join:
import { joinWaitlist } from "./api.js"; export default function App() { async function join(formData) { await joinWaitlist(formData.get("email")); } return ( <form action={join}> <label> Email <input name="email" type="email" required /> </label> <button>Join the waitlist</button> </form> );}Line 5 waits for the server. For a second nothing seems to happen, then the box empties. Did it work? Try asha@example.com, who is already on the list: the same thing happens, and the server's "already on the list" is lost. The form needs two more things: a place for the server's answer, and a way to show that it's waiting. useActionState gives both:
import { useActionState } from "react";import { joinWaitlist } from "./api.js"; async function join(previousState, formData) { const email = formData.get("email"); const result = await joinWaitlist(email); if (!result.ok) return result.error; return `Thanks! ${email} is on the list.`;} export default function App() { const [message, formAction, isPending] = useActionState(join, ""); return ( <form action={formAction}> <label> Email <input name="email" type="email" required /> </label> <button disabled={isPending}>{isPending ? "Joining…" : "Join the waitlist"}</button> <p>{message}</p> </form> );}Line 12 calls useActionState(join, ""): your action, and the state to start with. It returns three things, named here with array destructuring:
message, the state:""at first, then whateverjoinlast returned. Line 20 shows it.formAction, a new function to give the form instead ofjoin. Line 15 does. When the form is submitted, it callsjoinfor you.isPending, true whilejoinruns. Line 19 turns the button off and saysJoining….
join itself (lines 4 to 9) changes in one way: it now takes the previous state first and the FormData second. Whatever it returns becomes the new state, like a reducer that's allowed to wait for a server. Join with mint@example.com and the page says Thanks! mint@example.com is on the list. Join with asha@example.com and line 7 returns the server's error instead: asha@example.com is already on the list.
join with the current message as previousState and the form's data as formData. Whatever join returns becomes the new message, and the next submit starts from it. isPending is true while join runs.The names are yours to pick. react.dev calls the three state, dispatchAction, and isPending, and your function a reducer action; the React 19 announcement calls them error, submitAction, and isPending. Early previews of React 19 had this Hook in react-dom as useFormState; that name is deprecated. Watch one submit step by step:
import { useActionState } from "react";import { joinWaitlist } from "./api.js"; async function join(previousState, formData) { const email = formData.get("email"); const result = await joinWaitlist(email); if (!result.ok) return result.error; return `Thanks! ${email} is on the list.`;} export default function App() { const [message, formAction, isPending] = useActionState(join, ""); return ( <form action={formAction}> <label> Email <input name="email" type="email" required /> </label> <button disabled={isPending}>{isPending ? "Joining…" : "Join the waitlist"}</button> <p>{message}</p> </form> );}0 stepsWhat happened
Nothing yet.
Type an email like mint@example.com and press Join. The button is off while isPending is true, so you can't send twice. Watch the box: it keeps your email until the answer comes, then empties.
With the button Off while waiting, the log shows join starting, isPending turning true, the server answering a second later, and then one update: isPending false, the new message, and an empty box. With Always on, press Join twice: the second call starts only after the first has answered, and its previousState is the first call's answer. React queues the calls, because each one needs the state the one before it returned. That's usually a reason to turn the button off while isPending is true.
useFormStatus: a button that knows its form is busy
isPending lives in the component that renders the form. A submit button you reuse in many forms can't reach it without a prop. useFormStatus, from react-dom, lets a component inside a form ask that form directly. Type an email in the first form and press its button:
import { useFormStatus } from "react-dom";import { joinWaitlist } from "./api.js"; function SubmitButton({ children }) { const { pending, data } = useFormStatus(); return ( <button disabled={pending}> {pending ? `Sending ${data.get("email")}…` : children} </button> );} async function join(formData) { await joinWaitlist(formData.get("email"));} export default function App() { return ( <> <form action={join}> <input name="email" type="email" aria-label="Email for the waitlist" required /> <SubmitButton>Join the waitlist</SubmitButton> </form> <form action={join}> <input name="email" type="email" aria-label="Email for tasting invites" required /> <SubmitButton>Get tasting invites</SubmitButton> </form> </> );}Line 5 asks for the status of the form around SubmitButton. It returns an object with four properties:
pending: true while that form's action runs.data: theFormDatabeing sent, ornullwhen nothing is. Line 8 reads the email from it.methodandaction: the form's method and its action function, which you'll rarely need.
Join with mint@example.com: the first button turns off and says Sending mint@example.com…, while the second still says Get tasting invites. Each SubmitButton only follows the form it sits in (lines 20 to 27), and the forms need no props for it.
The form must be above the component that calls useFormStatus. A component that renders the form itself gets nothing. Press Join here:
import { useFormStatus } from "react-dom";import { joinWaitlist } from "./api.js"; async function join(formData) { await joinWaitlist(formData.get("email"));} function StatusLine() { const { pending } = useFormStatus(); return <p>StatusLine, inside the form: {pending ? "sending…" : "ready"}</p>;} export default function App() { const { pending } = useFormStatus(); return ( <form action={join}> <input name="email" type="email" aria-label="Email" required /> <button>Join</button> <StatusLine /> <p>App, which renders the form: {pending ? "sending…" : "ready"}</p> </form> );}While join waits, StatusLine says sending…: it calls useFormStatus on line 9 and is rendered inside the form on line 19. App's line stays ready, because App calls it on line 14 and there's no form above App; the form is something App renders below itself.
useFormStatus looks up the tree for the closest <form> above the component that calls it. Left: SubmitButton sits inside the form, so it sees pending: true. Right: App renders the form but has none above it, so pending stays false.How to read the table: each row asks one question, answered for each Hook.
| Question | useActionState | useFormStatus |
|---|---|---|
| Where it comes from | react | react-dom |
| Where you call it | In the component that renders the form. | In a component inside the form, like its button. |
| What it gives you | [state, formAction, isPending]: the last result, the function for action, and whether it's running. | { pending, data, method, action } for the form around it. |
| What it's for | Showing the answer: a message, errors, the values to keep. | A submit button or a waiting note you can reuse in any form. |
When React empties the form
Every action form so far emptied its boxes on its own. React resets a form after its action, and the exact rule matters, because it surprises people. Look back at the waitlist: join with asha@example.com and the box is empty when the error appears. The person has to type the email again to fix it.
join runs. When it returns, React shows the new message and resets the form in the same update, so the error and the empty box appear together. React resets even though the answer is an error, because join finished without throwing.Three facts explain it:
- When: React resets the form when the action finishes, not when you press the button. The text stays while the action runs, and the reset arrives in the same update as the new state.
- Whatever it returns: React doesn't read your result. Any action that finishes without throwing counts as done, even one that returns an error message.
- What it resets: React calls the browser's
form.reset(), which puts each field back to its default value. A controlled field still shows your state afterwards, because the reset doesn't change state.
The fix for the lost email uses the first fact. React updates every field's defaultValue first and resets the form after, so you can send the typed value back in the state and make it the default:
import { useActionState } from "react";import { joinWaitlist } from "./api.js"; async function join(previousState, formData) { const email = formData.get("email"); const result = await joinWaitlist(email); if (!result.ok) return { message: result.error, email }; return { message: `Thanks! ${email} is on the list.`, email: "" };} export default function App() { const [state, formAction, isPending] = useActionState(join, { message: "", email: "" }); return ( <form action={formAction}> <label> Email <input name="email" type="email" defaultValue={state.email} required /> </label> <button disabled={isPending}>{isPending ? "Joining…" : "Join the waitlist"}</button> <p>{state.message}</p> </form> );}The state is now an object. On an error, line 7 returns the message and the email; on success, line 8 returns an empty email. Line 17 uses state.email as the box's defaultValue. Join with asha@example.com: the error appears, and the box still holds asha@example.com, ready to fix. Join with mint@example.com: the thanks appear, and the box is empty.
Try every combination: what the server says, and whether the email box gets its value back.
import { useActionState, useState } from "react"; async function save(previousState, formData) { const email = formData.get("email"); await new Promise((resolve) => setTimeout(resolve, 800)); return { message: `${email} is already on the list.`, email };} export default function App() { const [name, setName] = useState("Asha"); const [state, formAction] = useActionState(save, { message: "", email: "" }); return ( <form action={formAction}> <input name="name" aria-label="Name" value={name} onChange={(e) => setName(e.target.value)} /> <input name="email" aria-label="Email" /> <button>Save</button> <p>{state.message}</p> </form> );}waiting for SaveType an email, then press Save. Watch both boxes when the answer comes back.
How to read the table: each row is something that happened, whether React resets the form, and why.
| What happened | Does React reset the form? | Why |
|---|---|---|
| The action returned, whatever it returned | Yes, when the action finishes. | It finished without throwing, so React treats it as done. |
| The action threw an error | No. | React stops and shows the nearest error boundary instead. |
| The form used onSubmit and no action | No, unless you call requestFormReset. | React only resets forms for actions it runs. |
| A field is controlled | Its text stays. | It shows your state, and the reset doesn't change state. |
These links point at React 19.3.1. When you submit, FormActionEventPlugin.js stops the page load, builds the FormData, and starts the action:
// A form action was provided. Prevent native navigation.
event.preventDefault();
// Dispatch the action and set a pending form status.
const formData = new FormData(form, submitter);
const pendingState: FormStatus = {
pending: true,
data: formData,
method: form.method,
action: action,
};
startHostTransition(formInst, pendingState, action, formData);ReactFiberHooks.js asks for the reset as the action starts, inside the same Transition, so it only happens when the action completes:
: () => {
// Automatically reset the form when the action completes.
requestFormReset(formFiber);
return action(formData);
},And ReactFiberCommitWork.js resets the form after every other change in that update, which is why a new defaultValue is already in place:
if (needsFormReset) {
// A form component requested to be reset during this commit. We do this
// after all mutations in the rest of the tree so that `defaultValue`
// will already be updated. This way you can update `defaultValue` using
// data sent by the server as a result of the form submission.
needsFormReset = false;
recursivelyResetForms(finishedWork);
}Menus don't follow a new defaultValue
One kind of field breaks the defaultValue trick: a <select>. React reads a menu's defaultValue only when the menu first appears, so after a reset the menu goes back to its first default. Pick Large and press Choose:
import { useActionState } from "react"; function pick(previousState, formData) { return formData.get("size");} export default function App() { const [size, formAction] = useActionState(pick, "small"); return ( <form action={formAction}> <select name="size" aria-label="Size" defaultValue={size}> <option value="small">Small</option> <option value="large">Large</option> </select> <button>Choose</button> <p>You chose: {size}</p> </form> );}The state says large, and line 17 shows You chose: large, but the menu on line 12 shows Small again. Press Edit & run and add key={size} to the <select>: a new key gives React a new menu, which reads defaultValue again (the Preserving and resetting state lesson explains keys). Radio buttons don't have this problem: React updates defaultChecked on every render, as the review form below relies on.
Resetting by hand with requestFormReset
A form with onSubmit is never reset by React. When you want React's reset there, react-dom has requestFormReset(form). It asks for the same reset an action gets, so it must be called inside a Transition, and the reset happens when that Transition finishes. react.dev has no reference page for it yet; the React 19 changelog announces it next to form actions. Here a booking form checks the number first, and only books, and resets, when it's fine:
import { startTransition, useState } from "react";import { requestFormReset } from "react-dom";import { bookSeats } from "./api.js"; export default function App() { const [error, setError] = useState(""); function handleSubmit(e) { e.preventDefault(); const form = e.currentTarget; const seats = Number(new FormData(form).get("seats")); if (seats > 4) { setError("You can book up to 4 seats."); return; } setError(""); startTransition(async () => { requestFormReset(form); await bookSeats(seats); }); } return ( <form onSubmit={handleSubmit}> <label> Seats at the tasting <input name="seats" type="number" min="1" defaultValue="2" /> </label> <button>Book</button> <p>{error}</p> </form> );}Lines 12 to 15 stop at a number over 4, before any Transition starts: type 6 and press Book, and the error shows while the box keeps your 6. For 3, line 17 starts a Transition with startTransition from react, line 18 asks for the reset, and line 19 waits for the server. The box keeps the 3 while it waits, then goes back to its default, 2, when bookSeats answers. Calling the browser's form.reset() instead would empty it at once, before the booking is done.
When an action fails
Actions fail in two ways, and each gets its own treatment:
- Errors you expect, like an email already on the list or a comment that's too short. Return them as part of the state and show them next to the fields, as the waitlist does. The form stays usable.
- Errors you don't expect, like a server that's down or a bug in your code. Let them throw. React stops the action and shows the closest error boundary: a component that shows something else when a part of the page fails.
Here the action always throws. The ErrorBoundary.jsx tab is ready-made; the Error boundaries lesson shows how to write one. Press Order and wait a second:
import ErrorBoundary from "./ErrorBoundary.jsx"; async function sendOrder(formData) { await new Promise((resolve) => setTimeout(resolve, 1000)); throw new Error("The tea shop's server is down.");} export default function App() { return ( <ErrorBoundary> <form action={sendOrder}> <label> Tea <input name="tea" defaultValue="Chai" /> </label> <button>Order</button> </form> </ErrorBoundary> );}Line 5 throws after the wait. React stops the action and line 10's ErrorBoundary replaces the form with Something went wrong: The tea shop's server is down. The console under the result shows the error React reported. Without a boundary, React removes the whole app: press Edit & run, delete lines 10 and 17, and press Order. The result is replaced by the error itself.
A thrown error also cancels any submits still waiting in the queue, so for anything you can foresee, return a message instead. And check simple things in the action before calling the server at all: returning early is fast and needs no network, as the review form below does. The browser's own checks still work too: a required field stops the submit before your action runs.
A product review form
Here is everything together: an action that checks the fields and returns errors, sends the review when it's fine, keeps what people typed after an error, and a reusable button that knows when the form is busy. Press Post review with nothing filled in first.
import { useActionState } from "react";import { useFormStatus } from "react-dom";import { saveReview } from "./api.js"; const blank = { errors: {}, values: { name: "", rating: "5", comment: "" }, saved: "" }; async function postReview(previousState, formData) { const values = Object.fromEntries(formData); const errors = {}; if (values.name.trim() === "") errors.name = "Tell us your name."; if (values.comment.trim().length < 10) errors.comment = "Write at least 10 characters."; if (Object.keys(errors).length > 0) return { errors, values, saved: "" }; await saveReview(values); return { ...blank, saved: `Thanks, ${values.name}! Your ${values.rating}-star review is up.` };} function SubmitButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? "Posting…" : "Post review"}</button>;} export default function App() { const [state, formAction] = useActionState(postReview, blank); const { errors, values } = state; return ( <form action={formAction} noValidate> {state.saved && <p>{state.saved}</p>} <label htmlFor="name">Name</label> <input id="name" name="name" defaultValue={values.name} aria-invalid={Boolean(errors.name)} aria-describedby="name-error" /> <p id="name-error" className="error">{errors.name}</p> <fieldset> <legend>Rating</legend> {["5", "4", "3"].map((stars) => ( <label key={stars}> <input type="radio" name="rating" value={stars} defaultChecked={values.rating === stars} /> {stars} stars </label> ))} </fieldset> <label htmlFor="comment">Comment</label> <textarea id="comment" name="comment" defaultValue={values.comment} aria-invalid={Boolean(errors.comment)} aria-describedby="comment-error" /> <p id="comment-error" className="error">{errors.comment}</p> <SubmitButton /> </form> );}postReview (lines 7 to 15) turns the FormData into a plain object with Object.fromEntries on line 8, and checks it. With errors, line 12 returns them together with the values, without calling the server. Lines 30, 37, and 42 use those values as defaults, so after the reset each field shows what was typed, and the radio button you picked stays picked. aria-invalid and aria-describedby on lines 31 and 43 point a screen reader at each message, as in Forms and inputs.
Fill in Asha, pick 3 stars, write short, and press Post review: only the comment's error shows, and your name, rating, and comment are all still there. Write Smooth and malty. and post again. SubmitButton (lines 17 to 20) says Posting… for a second, then line 14 returns the blank state with a thank-you, and line 28 says Thanks, Asha! Your 3-star review is up. The fields go back to blank and 5 stars.
- An uncontrolled box, and the action returns
{ ok: true } - An uncontrolled box, and the action returns an error message
- A box with
defaultValue={state.email}, and the action returns the email in the state - A controlled box,
value={email}withonChange, in a form with an action - A form with
onSubmitande.preventDefault(), and no action - A form with
onSubmitthat callsrequestFormReset(form)insidestartTransition - The pressed button has its own
formAction={saveForLater}
Each card describes a form after you submit it with an email typed in. Sort it: does the email box empty, or keep its text?
Common mix-ups
- "An action still needs e.preventDefault()." No. An action never gets the event; React stops the page load itself.
- "With useActionState, the FormData is the action's first argument." No. The previous state comes first, the FormData second.
- "useFormStatus works in the component that renders the form." No. It reads the form above the component that calls it, so put it in a child, like a submit button.
- "useFormStatus comes from react." No. It comes from
react-dom;useActionStatecomes fromreact. - "React only resets the form when the action succeeds." Not in the way most people mean. It resets after any action that finishes without throwing, including one that returns an error message.
- "The form resets as soon as you press submit." No. The fields keep their text while the action runs, and the reset comes with the new state.
Practice exercises
Each exercise shows its code's result, or shows it when you press Edit & run. The slow ones import the same pretend server, in the api.js tab. Try a guess first, then use the hints.
Turn this onSubmit form into one with an action. After your change, you add the note Malty. What's in the box?
import { useState } from "react"; export default function App() { const [notes, setNotes] = useState([]); function handleSubmit(e) { e.preventDefault(); const formData = new FormData(e.currentTarget); setNotes([...notes, formData.get("note")]); } return ( <form onSubmit={handleSubmit}> <input name="note" aria-label="Tasting note" required /> <button>Add note</button> <ul> {notes.map((note, index) => ( <li key={index}>{note}</li> ))} </ul> </form> );}import { useState } from "react"; export default function App() { const [notes, setNotes] = useState([]); function addNote(formData) { setNotes([...notes, formData.get("note")]); } return ( <form action={addNote}> <input name="note" aria-label="Tasting note" required /> <button>Add note</button> <ul> {notes.map((note, index) => ( <li key={index}>{note}</li> ))} </ul> </form> );}addNote gets the FormData from React, so it needs neither e.preventDefault() nor new FormData. And because it's an action, React resets the form when it finishes: the note joins the list and the box is empty, ready for the next one.
Press Send: the button never changes. Fix it so it says Sending… while it waits. What does it say then?
import { useFormStatus } from "react-dom";import { joinWaitlist } from "./api.js"; async function join(formData) { await joinWaitlist(formData.get("email"));} export default function App() { const { pending } = useFormStatus(); return ( <form action={join}> <input name="email" type="email" aria-label="Email" required /> <button disabled={pending}>{pending ? "Sending…" : "Send"}</button> </form> );}import { useFormStatus } from "react-dom";import { joinWaitlist } from "./api.js"; async function join(formData) { await joinWaitlist(formData.get("email"));} function SendButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? "Sending…" : "Send"}</button>;} export default function App() { return ( <form action={join}> <input name="email" type="email" aria-label="Email" required /> <SendButton /> </form> );}useFormStatus reads the form above the component that calls it. App renders the form, so it has none above. SendButton is rendered inside the form, so its pending is true while join waits, and it says Sending….
Make the waitlist show the server's answer. What does the page say after you join with mint@example.com?
import { joinWaitlist } from "./api.js"; export default function App() { async function join(formData) { await joinWaitlist(formData.get("email")); } return ( <form action={join}> <label> Email <input name="email" type="email" required /> </label> <button>Join the waitlist</button> </form> );}import { useActionState } from "react";import { joinWaitlist } from "./api.js"; async function join(previousState, formData) { const email = formData.get("email"); const result = await joinWaitlist(email); return result.ok ? `Thanks! ${email} is on the list.` : result.error;} export default function App() { const [message, formAction] = useActionState(join, ""); return ( <form action={formAction}> <label> Email <input name="email" type="email" required /> </label> <button>Join the waitlist</button> <p>{message}</p> </form> );}useActionState keeps what join returns, so the message survives the render. join now takes the previous state first. For mint@example.com the server says yes, so the page says Thanks! mint@example.com is on the list.; for asha@example.com it shows the server's error.
Join with asha@example.com: the error shows and the box empties. Make the box keep the email after an error. What's in the box then?
import { useActionState } from "react";import { joinWaitlist } from "./api.js"; async function join(previousState, formData) { const email = formData.get("email"); const result = await joinWaitlist(email); if (!result.ok) return result.error; return `Thanks! ${email} is on the list.`;} export default function App() { const [message, formAction, isPending] = useActionState(join, ""); return ( <form action={formAction}> <label> Email <input name="email" type="email" required /> </label> <button disabled={isPending}>{isPending ? "Joining…" : "Join the waitlist"}</button> <p>{message}</p> </form> );}import { useActionState } from "react";import { joinWaitlist } from "./api.js"; async function join(previousState, formData) { const email = formData.get("email"); const result = await joinWaitlist(email); if (!result.ok) return { message: result.error, email }; return { message: `Thanks! ${email} is on the list.`, email: "" };} export default function App() { const [state, formAction, isPending] = useActionState(join, { message: "", email: "" }); return ( <form action={formAction}> <label> Email <input name="email" type="email" defaultValue={state.email} required /> </label> <button disabled={isPending}>{isPending ? "Joining…" : "Join the waitlist"}</button> <p>{state.message}</p> </form> );}On an error, join returns { message, email }, and the box's defaultValue becomes that email. React resets the form after updating defaults, so the box goes back to asha@example.com. On success it returns an empty email, so the box empties.
Add a Save draft button that calls its own action. You press it. What does the status line say?
import { useState } from "react"; export default function App() { const [status, setStatus] = useState("Nothing yet."); function publish(formData) { setStatus(`Published: ${formData.get("review")}`); } return ( <form action={publish}> <textarea name="review" aria-label="Your review" defaultValue="Great chai" /> <button>Publish</button> <p>{status}</p> </form> );}import { useState } from "react"; export default function App() { const [status, setStatus] = useState("Nothing yet."); function publish(formData) { setStatus(`Published: ${formData.get("review")}`); } function saveDraft(formData) { setStatus(`Draft saved: ${formData.get("review")}`); } return ( <form action={publish}> <textarea name="review" aria-label="Your review" defaultValue="Great chai" /> <button>Publish</button> <button formAction={saveDraft}>Save draft</button> <p>{status}</p> </form> );}formAction on the new button overrides the form's action for that button only. Pressing it calls saveDraft with the same FormData, so the status says Draft saved: Great chai. It's an action too, so the text area goes back to its default afterwards.
Make the action refuse anything that isn't a whole number. You type two and press Save. What does the page say?
import { useActionState } from "react"; async function saveCups(previousState, formData) { const cups = Number(formData.get("cups")); return `Saved: ${cups} cups a day.`;} export default function App() { const [message, formAction] = useActionState(saveCups, ""); return ( <form action={formAction}> <label> Cups a day <input name="cups" defaultValue="3" /> </label> <button>Save</button> <p>{message}</p> </form> );}import { useActionState } from "react"; async function saveCups(previousState, formData) { const cups = Number(formData.get("cups")); if (!Number.isInteger(cups) || cups < 0) return "Enter a whole number of cups."; return `Saved: ${cups} cups a day.`;} export default function App() { const [message, formAction] = useActionState(saveCups, ""); return ( <form action={formAction}> <label> Cups a day <input name="cups" defaultValue="3" /> </label> <button>Save</button> <p>{message}</p> </form> );}The starter saves anything, so two shows Saved: NaN cups a day.. The check returns an error message instead, which becomes the state. The action still finished, so React reset the box to 3; Exercise 4's defaultValue trick would keep two there.
Check your understanding
For each code question, find which function React calls and with what, then follow its result to the screen. Press Edit & run on a question's code to check your answer.
Question 1 of 7What does React pass to a function you give a form's
action?Choose an answer to see the explanation.
Question 2 of 7You press Join. What happens?
Read the code, then predictimport { useActionState } from "react"; async function join(formData) { return `Thanks, ${formData.get("email")}!`; } export default function App() { const [message, formAction] = useActionState(join, ""); return ( <form action={formAction}> <input name="email" aria-label="Email" defaultValue="mint@example.com" /> <button>Join</button> <p>{message}</p> </form> ); }Choose an answer to see the explanation.
Question 3 of 7You press Send. What does the button say during the second it waits?
Read the code, then predictimport { useFormStatus } from "react-dom"; async function send() { await new Promise((resolve) => setTimeout(resolve, 1000)); } export default function App() { const { pending } = useFormStatus(); return ( <form action={send}> <button>{pending ? "Sending…" : "Send"}</button> </form> ); }Choose an answer to see the explanation.
Question 4 of 7You type asha@example.com and press Join. What's in the box when the message appears?
Read the code, then predictimport { useActionState } from "react"; async function join(previousState, formData) { return `${formData.get("email")} is already on the list.`; } export default function App() { const [message, formAction] = useActionState(join, ""); return ( <form action={formAction}> <input name="email" aria-label="Email" /> <button>Join</button> <p>{message}</p> </form> ); }Choose an answer to see the explanation.
Question 5 of 7Where do you import
useFormStatusfrom?Choose an answer to see the explanation.
Question 6 of 7With
useActionState, you press Join twice before the first answer comes back. What happens?Choose an answer to see the explanation.
Question 7 of 7When does React reset a form whose
actionis a function?Choose an answer to see the explanation.
Key takeaways
- Pass a function to a form's
action, and React stops the page load, calls it with the form'sFormData, and runs it in a Transition. A button'sformActionoverrides it for that button. useActionState(action, initialState)returns[state, formAction, isPending]. The action gets(previousState, formData), and what it returns becomes the state. Calls queue, one after another.useFormStatus(), fromreact-dom, gives a component inside a formpendinganddatafor that form. It sees nothing in the component that renders the form.- React resets the form when the action finishes without throwing, whatever it returns, in the same update as the new state. Controlled fields keep your state; send typed values back as
defaultValueto keep them after an error. - Return expected errors as state. Let unexpected ones throw to an error boundary.
requestFormReset(form)asks for the reset yourself, inside a Transition.
Remember the one-liner.
Give the form a function: React sends it the FormData, tracks the wait, and resets the form when it's done.
Coming next: Optimistic updates. With useOptimistic, the page shows the result of an action before the server confirms it, and rolls back if it fails.