cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Hand a form a functionPass a function to a form's action, read the FormData React gives it, and drop preventDefault.
  • 02
    Show the answer and the waitKeep an action's result and its pending flag with useActionState, and build a reusable submit button with useFormStatus.
  • 03
    Know 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:

An order form whose action is a functionPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

Five steps. Top row, left to right: you press Add, React stops the page load, React builds new FormData from the form. An arrow goes down to the bottom row, right to left: your addTea of formData runs, then React calls form.reset when it finishes.you press Addno page loadnew FormDataReactReactaddTea(formData)form.reset()your functionReact, when it finishes
Pressing Add hands the work to React. It stops the browser from loading a new page, builds a 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.

Real-life analogyThe parcel counter

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
The main idea

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:

The same form with onSubmitPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

onSubmit or a function in action
QuestiononSubmitA 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:

Add to cart or save for laterPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

A waitlist form that waits in silencePop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

The waitlist with useActionStatePop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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 whatever join last returned. Line 20 shows it.
  • formAction, a new function to give the form instead of join. Line 15 does. When the form is submitted, it calls join for you.
  • isPending, true while join runs. Line 19 turns the button off and says Joining….

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.

A loop. A box called message holds an empty string. An arrow labelled previousState goes to a card reading join of previousState and formData. From below, a card reading email: mint@example.com feeds in as formData. An arrow labelled returns goes down to a card with the returned text, Thanks, and an arrow from it goes up into the message box: it becomes the message. A note says isPending is true until join returns.""messagepreviousStatejoin(previousState, formData)isPending is true until it returnsemail: mint@example.comformDatareturns"Thanks!…"becomes message
React calls 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:

Playground: one submit, step by step
The waitlist formJSX
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>  );}
The page0 steps

What happened

Nothing yet.

Try it yourself
One press, one call.

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.

The waitlist form runs here, with React on this page and a pretend server that answers after one second, as in the code. Times count from your first press. Picking a way starts again with an empty log.

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:

One SubmitButton in two formsPop out in the code editor (opens in a new tab)JSX
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>    </>  );}
ResultLoading…

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: the FormData being sent, or null when nothing is. Line 8 reads the email from it.
  • method and action: 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:

useFormStatus in the component that renders the formPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

Two trees. Left: App renders a form, and the form holds SubmitButton, which calls useFormStatus and reads pending: true. Right: App calls useFormStatus and renders the form below it. There is no form above App, so its pending stays false.inside the formApp<form action>SubmitButtonpending: truerendering the formAppuseFormStatus()<form action>no form above Apppending: false
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.

useActionState and useFormStatus
QuestionuseActionStateuseFormStatus
Where it comes fromreactreact-dom
Where you call itIn 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 forShowing 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.

Three page windows over time. First, you press Join with asha@example.com in the box. Second, while join runs, the box still holds asha@example.com and the button says Joining. Third, the answer arrives: the message says the email is already on the list, and the box is empty.0.0 sasha@example.comJoin0.5 sasha@example.comJoining…1.0 salready on the listyou press Joinjoin runsjoin returnsthe text stays until the answermessage and reset
The email stays in the box while 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:

  1. 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.
  2. 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.
  3. 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:

A waitlist that keeps the email after an errorPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

Playground: what survives the reset?
A controlled name and an uncontrolled emailJSX
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>  );}
The pagewaiting for Save

Try it yourself
Press Save.

Type an email, then press Save. Watch both boxes when the answer comes back.

The form runs here, with React on this page, written the way the code shows; its pretend server answers after 0.8 seconds. Type an email, press Save, and read both boxes when the answer comes. Picking an option starts again.

How to read the table: each row is something that happened, whether React resets the form, and why.

Does React reset the form?
What happenedDoes React reset the form?Why
The action returned, whatever it returnedYes, when the action finishes.It finished without throwing, so React treats it as done.
The action threw an errorNo.React stops and shows the nearest error boundary instead.
The form used onSubmit and no actionNo, unless you call requestFormReset.React only resets forms for actions it runs.
A field is controlledIts text stays.It shows your state, and the reset doesn't change state.
Read it in React's own code

These links point at React 19.3.1. When you submit, FormActionEventPlugin.js stops the page load, builds the FormData, and starts the action:

FormActionEventPlugin.js, lines 134 to 148, shortenedJavaScript
// 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:

ReactFiberHooks.js, lines 3301 to 3305JavaScript
: () => {
    // 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:

ReactFiberCommitWork.js, lines 2407 to 2419, shortenedJavaScript
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:

A menu that forgets the choice after the resetPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

A booking form that resets only after a bookingPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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:

An action that throws, inside an error boundaryPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

A review form with an actionPop out in the code editor (opens in a new tab)JSX
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>  );}
ResultLoading…

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.

Does the email box keep its text?
  • 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} with onChange, in a form with an action
  • A form with onSubmit and e.preventDefault(), and no action
  • A form with onSubmit that calls requestFormReset(form) inside startTransition
  • The pressed button has its own formAction={saveForLater}
Try it yourself
0 of 7 correct

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?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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; useActionState comes from react.
  • "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.

Exercise 1 · Warm-upHand the form to React

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>  );}
ResultLoading…

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhy does it never say Sending…?

    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>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeShow the server's answer

      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>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep the email after an 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>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeAdd a Save draft button

          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>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeCheck the number before saving

            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>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The form actions quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does React pass to a function you give a form's action?

                Choose an answer to see the explanation.

              2. Question 2 of 7You press Join. What happens?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              3. Question 3 of 7You press Send. What does the button say during the second it waits?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              4. Question 4 of 7You type asha@example.com and press Join. What's in the box when the message appears?

                Read the code, then predictPop out in the code editor (opens in a new tab)JSX
                import { 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.

              5. Question 5 of 7Where do you import useFormStatus from?

                Choose an answer to see the explanation.

              6. Question 6 of 7With useActionState, you press Join twice before the first answer comes back. What happens?

                Choose an answer to see the explanation.

              7. Question 7 of 7When does React reset a form whose action is 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's FormData, and runs it in a Transition. A button's formAction overrides 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(), from react-dom, gives a component inside a form pending and data for 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 defaultValue to 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.

              CompleteFrontend Clear concepts. Working examples.