Pass a function to a form’s action prop, and React calls it with the form’s data. Learn useActionState for results and errors, useFormStatus for a busy button, and the form reset.
In Part 6 we sent forms with onSubmit and e.preventDefault(). In Part 13 we sent a message to a fake server. We needed a status state to show “Sending…” and to turn the button off.
React 19 has a shorter way: form Actions. You give the form a function, and React does the rest. It stops the page from loading again. It collects the form’s data. It tells you when the form is busy. And when the function is done, it sets the form’s uncontrolled boxes back.
This part covers that function and two Hooks. useActionState is for results and errors. useFormStatus is for a busy button. Part 28 explained controlled and uncontrolled inputs and FormData. We use both here, and we explain each again briefly.
Try this first
Read this code. Don’t press Run yet.
// A fake server. It takes 800 ms to save a message.
function sendMessage(text: string): Promise<void> {
console.log('Server: got the message', text)
return new Promise(resolve => {
setTimeout(resolve, 800)
})
}
export default function App() {
async function send(formData: FormData) {
const text = String(formData.get('message'))
console.log('Action: sending', text)
await sendMessage(text)
console.log('Action: done')
}
return (
<form action={send}>
<input name="message" aria-label="Message" />
<button type="submit">Send</button>
</form>
)
}
Action: sending Hi
Server: got the message Hi
Action: done
sendMessage pretends to be a server. It waits 800 ms and then answers.
There is no onSubmit here, and no e.preventDefault(). Part 6 said that a form loads the page again when you send it. Make two guesses:
- When you click Send, will the page load again and lose everything?
- What will happen to the text in the box?
Now press Run. Type “Hi” and click Send.
The page doesn’t load again. The Console shows the three lines above. And a moment later, the box is empty.
What React does for you
<form action={send}> gives the form a function instead of a web address. React’s docs call this function an Action. When the form is sent, React does four things.
- It stops the page from loading again. React’s
<form>page says that with an action, “callinge.preventDefault()isn’t needed”. We checked in React 19.3: the browser’ssubmitevent haddefaultPreventedset totrue, and the page address did not change. We also tried it in a real browser, Chromium. The playground’s result box was not loaded again. - It collects the data. React calls your function with one value: a
FormDataobject. It holds the values of the form’s named fields. We checked:sendgot exactly one argument, and it was aFormData. - It runs your function as a Transition. This is where the “busy” state comes from. We’ll see it soon.
- It sets the form back when your function is done. “Reset” means each box goes back to its default text. Our box had none, so it became empty. In the run above, the box still showed “Hi” 400 ms after the click. At 1000 ms it was empty.
The action ran once for one click, with Strict Mode on. Strict Mode runs components twice in development (Part 2). An action is not a component. React calls it once for each time the form is sent.
The old way still works. React’s docs say onSubmit “works in every version of React” and gives you the submit event itself. Use an action when you want the four things above.
FormData: every field needs a name
FormData is the browser’s object for a form’s data. It keeps one entry for each named field that has a value, under the field’s name. You read an entry with formData.get('name').
export default function App() {
function save(formData: FormData) {
console.log('name:', formData.get('name'))
console.log('city:', formData.get('city'))
}
return (
<form action={save}>
<input name="name" aria-label="Name" />
<input aria-label="City" />
<button type="submit">Save</button>
</form>
)
}
name: Ana
city: null
Type “Ana” and “Rome”, then click Save. The city is null. The second box has no name, so it isn’t in the form’s data at all. The fix is <input name="city" />.
formData.get gives back text, a file, or null. That’s why the first example wraps it in String(...): TypeScript then knows text is a string. But be careful: String(null) is the text "null". We removed the name from the guest book’s box below, and it signed the name "null".
Which boxes are reset?
React does the reset only after the action is done. React’s <form> page says: “After the action function succeeds, all uncontrolled field elements in the form are reset.”
An uncontrolled box keeps its own text. React doesn’t hold that text in state. A controlled box gets its text from state, with value and onChange, as in Part 6. Part 28 compares the two.
Here are three boxes in one form:
import { useState } from 'react'
// A fake server. It takes 500 ms to save.
function save(): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, 500)
})
}
export default function App() {
const [city, setCity] = useState('')
async function saveForm(formData: FormData) {
await save()
console.log('Saved', formData.get('name'), formData.get('country'), formData.get('city'))
}
return (
<form action={saveForm}>
<input name="name" aria-label="Name" />
<input name="country" aria-label="Country" defaultValue="Spain" />
<input name="city" aria-label="City" value={city} onChange={e => setCity(e.target.value)} />
<button type="submit">Save</button>
</form>
)
}
Saved Ana Italy Rome
Run it. Type “Ana” in the first box. Change “Spain” to “Italy”. Type “Rome” in the last box. Click Save and wait.
We measured the boxes after the action:
| Box | Kind | After the action |
|---|---|---|
| Name | uncontrolled, no defaultValue |
empty |
| Country | uncontrolled, defaultValue="Spain" |
Spain |
| City | controlled, value={city} |
keeps its text: Rome |
So “reset” doesn’t always mean “empty”. A box goes back to its defaultValue, as it is now. The controlled box isn’t touched at all. Its text comes from state, and React doesn’t change your state for you. Common mistakes shows how to empty it.
Actions are Transitions
Before more form features, we need one word: Transition. Part 25 met it as an update that is non-blocking. That means the user can keep using the page while React works on it.
React 19 lets a Transition run an async function. The useTransition Hook gives you two things: isPending, which is true while the work is going on, and startTransition, to start it.
import { useTransition } from 'react'
// A fake server. It takes 800 ms to save.
function saveSettings(): Promise<void> {
console.log('Server: saving')
return new Promise(resolve => {
setTimeout(resolve, 800)
})
}
export default function App() {
const [isPending, startTransition] = useTransition()
function handleClick() {
startTransition(async () => {
await saveSettings()
console.log('Saved')
})
}
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? 'Saving...' : 'Save settings'}
</button>
)
}
Server: saving
Saved
Click the button. It says “Saving…” and is turned off right away. After 800 ms it is back. We checked: “Saving…” was there right after the click and at 400 ms. At 1000 ms the button said “Save settings” again.
React’s docs give the name: “Functions called in startTransition are called “Actions”.” A form’s action is the same kind of thing. React starts the Transition for you when the form is sent.
There is one rule to know. React’s docs say that state updates after an await “are not marked as Transitions”. So if you call a set function after await, the docs say you currently need another startTransition around it. Without it, the docs warn, “you might see the updates happen out of order”. useActionState, below, avoids most of this: you return the new state instead of setting it.
If you ever need to reset a form yourself, react-dom has a function for it, requestFormReset(form). Call it inside an action or a Transition. We called it from a plain click handler. It still reset the box, but React printed: requestFormReset was called outside a transition or action.
useFormStatus: a button that knows the form is busy
Our first form had no “Sending…” and no turned-off button. The useFormStatus Hook from react-dom gives us both. It tells a component about the form it is inside.
import { useFormStatus } from 'react-dom'
// A fake server. It takes 800 ms to save a message.
function sendMessage(text: string): Promise<void> {
console.log('Server: got the message', text)
return new Promise(resolve => {
setTimeout(resolve, 800)
})
}
function SendButton() {
const { pending, data } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? `Sending "${data?.get('message')}"...` : 'Send'}
</button>
)
}
export default function App() {
async function send(formData: FormData) {
await sendMessage(String(formData.get('message')))
}
return (
<form action={send}>
<input name="message" aria-label="Message" />
<SendButton />
</form>
)
}
Server: got the message Hi
Type “Hi” and click Send. The button says Sending "Hi"... and is turned off. After the server answers, it says “Send” again, and the box is empty.
Compare this with the Part 13 form. There we had a status state, and we set it to 'sending' and then to 'sent' by hand. Here React tracks it for us.
useFormStatus() gives back an object with four fields. We logged them in React 19.3:
| Field | While the form is busy | When it is not busy |
|---|---|---|
pending |
true |
false |
data |
the FormData being sent |
null |
method |
'get' (our form sets no method) |
null |
action |
our send function |
null |
method only repeats the form’s method attribute. Our action is a plain function in the browser. The form doesn’t send anything over the network by itself, and the page address stays the same.
Why it must be in a child
Notice that useFormStatus is in SendButton, not in App. That isn’t a style choice. The Hook reads the form above the component that calls it. React’s docs say it “will only return status information for a parent <form>“. App renders the form, so in App there is no form above. Common mistakes shows what happens then.
React’s blog says useFormStatus “reads the status of the parent <form> as if the form was a Context provider”. So it works like context (Part 23). Any component inside the form can read it, however deep it is.
useActionState: results and errors
useFormStatus tells us when the form is busy. Often we also want a result. That could be a list that grows, or a message such as “That name is taken”. That is what useActionState is for.
const [state, signAction, isPending] = useActionState(addGuest, initialState)
You give it your action and a starting state. It gives back three things:
state: the last value your action returned. At first, it isinitialState.signAction: a new function that wraps yours. Pass it to the form:<form action={signAction}>. React’s docs call itdispatchAction. The name is up to you. We don’t call itformAction, because that is also the name of a button prop, later in this part.isPending:truewhile the action is running.
Your action gets two arguments now: the previous state first, then the form data. It returns the next state. That is the same shape as a reducer in Part 15. But this one may be async and talk to a server.
Watch out for one word. In Part 15, an “action” was an object, like { type: 'added' }, that you sent to the reducer. Here, the Action is your function itself. React’s docs call it reducerAction, because it works like a reducer and is also an Action. They call its second argument actionPayload: the data that comes with the call. For a form, it is the FormData.
Here is a guest book. You sign it with your name, and the names show in a list.
import { useActionState } from 'react'
type GuestState = { names: string[]; error: string | null }
// A fake server. It takes 800 ms to save a name.
function saveName(name: string): Promise<void> {
console.log('Server: saving', name)
return new Promise(resolve => {
setTimeout(resolve, 800)
})
}
async function addGuest(prevState: GuestState, formData: FormData): Promise<GuestState> {
const name = String(formData.get('name')).trim()
if (name === '') {
return { ...prevState, error: 'Please write a name.' }
}
if (prevState.names.includes(name)) {
return { ...prevState, error: `${name} has already signed.` }
}
await saveName(name)
return { names: [...prevState.names, name], error: null }
}
export default function App() {
const [state, signAction, isPending] = useActionState(addGuest, { names: [], error: null })
return (
<form action={signAction}>
<input name="name" aria-label="Name" />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Sign'}
</button>
{state.error && <p>{state.error}</p>}
<ul>
{state.names.map(name => <li key={name}>{name}</li>)}
</ul>
</form>
)
}
Server: saving Ana
Run it and try these, in order:
- Type “Ana” and click Sign. The button says “Saving…” and is turned off. After 800 ms, “Ana” is in the list.
- Type “Ana” again and click Sign. The page says “Ana has already signed.” The fake server is not asked.
- Click Sign with an empty box. The page says “Please write a name.”
- Type “Ben” and sign. The error goes away, and the list shows Ana and Ben.
We logged prevState each time addGuest ran. The first time it was {"names":[],"error":null}, the starting state. After Ana was saved, it was {"names":["Ana"],"error":null}. Each call got what the call before it returned. That is how step 2 knew that Ana had already signed.
Strict Mode is on, and addGuest still ran once for each click. React’s docs say Strict Mode does not call it twice. That’s because it is allowed to have side effects, like asking a server.
Here is everything that happens after one click, step by step.
One form Action from click to reset. Press play, or step through it.
- You type “Ana” and click Sign. React calls
addGuestwith the previous state and the form data. - The action starts.
isPendingbecomestrue, so the button is turned off and says “Saving…”. - The action waits 800 ms for the fake server.
- The server is done. The action returns
{ names: ['Ana'], error: null }. - React renders with the new state.
isPendingisfalseagain, and the form is reset, so the box is empty.
An everyday example
Think of a bank counter with a paper form. You fill in the form and hand it over. While the clerk works, a small “busy” sign is up. If you hand over another form, it waits in a line. Then the clerk gives you back a note with the result, and a new, empty form.
The paper form is the FormData. The busy sign is isPending. The note is the state your action returns. The clerk reads your last note before writing the next one. That is prevState.
The exact version
useActionState works like the one clerk: React’s docs say the calls wait in line and run one after another. We tested it. With the disabled removed, we clicked Sign twice quickly. The second call started at 800 ms, after the first one ended. Its prevState already had Ana in it.
Here the bank picture breaks. A plain <form action={fn}> with no useActionState has no line. Its calls can run side by side. Letting the user send twice shows that.
Also, the “new, empty form” isn’t always empty. As we saw, a box with a defaultValue goes back to that value. And a returned error still counts as success. We measured it: after “Ana has already signed.”, the box was empty. The user’s text was gone.
Do you want to keep what the user typed? Return it in the state, and give the box defaultValue={state.draft}. This works because React first renders with the new state, so defaultValue is now “Ana”. Then the reset puts each box back to its defaultValue. We tried this shape. After the error, the box showed “Ana” again.
Two buttons, two actions: formAction
A form can have more than one submit button. Give a button its own function with the formAction prop. That button then uses its own function instead of the form’s action.
export default function App() {
function publish(formData: FormData) {
console.log('Publish:', formData.get('title'))
}
function saveDraft(formData: FormData) {
console.log('Save draft:', formData.get('title'))
}
return (
<form action={publish}>
<input name="title" aria-label="Title" defaultValue="My first post" />
<button type="submit">Publish</button>
<button type="submit" formAction={saveDraft}>Save draft</button>
</form>
)
}
Save draft: My first post
Publish: My first post
Click Save draft, then Publish. Each button ran its own function. In our test we added “!” to the title first. Save draft logged My first post!. Then the form was reset to its defaultValue, so Publish logged My first post.
Errors: return them or throw them
An action can fail in two ways, and React treats them differently.
Return an error as state. For errors you expect, like “Please write a name.”, return them from the action. The guest book does this. React’s docs call these known errors, and say to “return it as part of your reducerAction state”. reducerAction is their name for the function you give useActionState. The form stays on the page, and you show the message.
Throw an error. For errors you don’t expect, the action can throw. React’s docs give an example of such an error: “undefined is not a function”. That is a bug in the code, not something the user can fix. Then React shows the nearest error boundary. An error boundary is a component that catches errors from the components inside it and shows something else. Part 32 builds one.
We tested both with an action that throws new Error('A bug in our code'). With an error boundary around the form, the page showed the boundary’s message. Without one, React removed the whole app from the page.
So, as a rule: return the errors a user can fix, and throw only for real bugs. A network failure that the user can try again is better returned as state. Show a message such as “Please try again.”
Server Functions and forms that work without JavaScript
You’ll see two more ideas next to form Actions. We name them here so they look familiar. Neither runs in this page’s playground.
- Server Functions. In some React apps, an action can be marked with
'use server'. React’s docs say this “marks server-side functions that can be called from client-side code”. The function then runs on the server, not in the browser. Part 33 explains them. - Progressive enhancement. An enhancement is something that makes a thing better. MDN describes the idea like this. The page should work for as many users as possible. Browsers that can run all the code get the best version. React’s
<form>page says such a form can be sent “without JavaScript enabled or before the code has loaded”. This works only when the action is a Server Function, in a framework that supports them. For that case,useActionStatetakes a third argument,permalink. It is the page address to go to if the form is sent too early.
Common mistakes
Calling useFormStatus in the form’s own component
import { useFormStatus } from 'react-dom'
// A fake server. It takes 800 ms to save a message.
function sendMessage(text: string): Promise<void> {
console.log('Server: got the message', text)
return new Promise(resolve => {
setTimeout(resolve, 800)
})
}
export default function App() {
const { pending } = useFormStatus()
async function send(formData: FormData) {
await sendMessage(String(formData.get('message')))
}
return (
<form action={send}>
<input name="message" aria-label="Message" />
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
</form>
)
}
Server: got the message Hi
Run it, type something and click Send. The button never says “Sending…”. The message is still sent, but pending stays false the whole time. We checked right after the click and at 200 ms. Both times, the button said “Send” and was on. React gives no warning.
App renders the <form>, so the form is not above App. The fix: move the button into its own component inside the form, like SendButton above.
Forgetting that the action gets prevState first
With useActionState, it’s easy to keep the old one-argument shape:
import { useActionState } from 'react'
type GuestState = { names: string[]; error: string | null }
async function addGuest(formData: FormData): Promise<GuestState> {
const name = String(formData.get('name'))
return { names: [name], error: null }
}
export default function App() {
const [state, signAction] = useActionState(addGuest, { names: [], error: null })
return (
<form action={signAction}>
<input name="name" aria-label="Name" />
<button type="submit">Sign</button>
<p>{state.names.join(', ')}</p>
</form>
)
}
TypeScript stops you here, with No overload matches this call. The playground doesn’t check types, so press Run, type a name and click Sign. React stops with formData.get is not a function. The first argument is the previous state, a plain object, and it has no get.
The fix: async function addGuest(prevState: GuestState, formData: FormData). React’s docs list this exact mistake: “The submitted form data is therefore its second argument instead of its first.”
Expecting a controlled input to reset
The City box in Which boxes are reset? kept “Rome” after the action. React sets back only uncontrolled boxes. It doesn’t change your state.
There are two fixes. Maybe you don’t need the text in state while the user types. Then drop value and onChange, and let the box be uncontrolled. Or, empty the state inside the action:
declare function save(): Promise<void>
declare function setCity(city: string): void
async function saveForm(formData: FormData) {
setCity('')
await save()
}
We tried setCity('') as the first line of the action. The box kept “Rome” while the action ran, and was empty when it was done. That’s because the update is part of the Transition, and React shows it when the action ends.
Letting the user send twice
Go back to Try this first and click Send twice, quickly. We did, and the Console showed two of each line:
Action: sending Hi
Server: got the message Hi
Action: sending Hi
Server: got the message Hi
Action: done
Action: done
The second click came before the first action ended. The box still said “Hi”, because the reset happens only at the end. So the same message went to the server twice.
The fix is to turn the button off while the form is busy: disabled={pending} from useFormStatus, or disabled={isPending} from useActionState. We made the same two quick clicks on SendButton and on the guest book. Each time the server was asked only once.
A field with no name
formData.get('city') gave null in the FormData example, because that box had no name:
const wrong = <input aria-label="City" />
The fix is to give every field a name, the key you read in the action:
const fixed = <input name="city" aria-label="City" />
If a value is null in your action, or the text "null" after String(...), check the field’s name first.
Practice
Press Edit on the examples above and try these.
- In “Try this first”, give the box
defaultValue="Hello". Type ” there” after it and click Send. What does the box show when the action is done? - Change the guest book’s fake server so it refuses the name “test”, in any capitals.
saveNameshould returnfalsefor it, and the page should say “That name is not allowed.” - In the guest book, add
console.log('addGuest runs')as the first line ofaddGuest. Sign one name. How many lines does the Console show in total? Strict Mode is on. - Fix the first example under Common mistakes, so the button says “Sending…” while the form is busy.
Answers
- “Hello”. The action logs
Action: sending Hello there. Then the form is reset, and the box goes back to itsdefaultValue. - Make
saveNameanswertrueorfalse. Then, inaddGuest, replace the lineawait saveName(name)with a check of that answer. Both pieces are under this list. Signing “Test” then shows “That name is not allowed.” and leaves the list as it was. - Two lines:
addGuest runsandServer: saving Ana. Strict Mode doesn’t call the action twice. - Move the button into its own component, which calls
useFormStatus(), and render that component inside the<form>. That is theSendButtonexample.
For answer 2, the new fake server and the lines that replace await saveName(name):
function saveName(name: string): Promise<boolean> {
console.log('Server: saving', name)
return new Promise(resolve => {
setTimeout(() => resolve(name.toLowerCase() !== 'test'), 800)
})
}
const ok = await saveName(name)
if (!ok) {
return { ...prevState, error: 'That name is not allowed.' }
}
Interview questions
Try to answer each one out loud before you open the answer.
What is an Action in React 19?
A function that React runs inside a Transition. You can start one with startTransition, or pass one to a form’s action prop. It may be async. While it runs, React tracks a pending state for you, and it can send thrown errors to an error boundary. React’s blog says: “By convention, functions that use async transitions are called “Actions”.”
A strong answer names the Hooks built on it: useActionState, useFormStatus and useOptimistic (that last one is Part 30).
How is <form action={fn}> different from onSubmit?
With action, React stops the page from loading again, so you don’t call e.preventDefault(). It calls your function with a FormData, not the event. It runs the function in a Transition, so useFormStatus and useActionState can show a pending state. And after the function succeeds, it sets the form’s uncontrolled fields back.
onSubmit gives you the event and nothing else. It works in every React version. A strong answer adds one more thing. Only action (or formAction) can take a Server Function. With one, the form can be sent before the JavaScript has loaded.
What does useActionState return, and what does its action receive?
It returns [state, dispatchAction, isPending]. React’s docs use those names. The action receives the previous state first, then the value it was called with. For a form, that value is the FormData. It returns the next state, and may be async.
A strong answer adds that the calls wait in line. They run one after another, each with the state the last one returned. It also says the action isn’t called twice in Strict Mode. And outside a form, you call dispatchAction inside startTransition. If you don’t, React prints: An async function with useActionState was called outside of a transition.
It may also say that in early test versions of React, this Hook was called ReactDOM.useFormState. React’s blog says it got its new name then, and useFormState is deprecated, which means you should stop using it.
Why does useFormStatus have to be called in a child of the form?
It reads the status of the <form> above the calling component, a bit like context. The component that renders the <form> has no form above it, so pending stays false. React gives no warning. The fix is a small component, like a submit button, rendered inside the form.
After a form Action, which fields are reset, and to what?
Uncontrolled fields go back to their defaultValue, as it is at the time of the reset. With no defaultValue, they become empty. Controlled fields keep their value, because their value comes from your state. The reset happens only after the action succeeds. A returned error object is still a success, so the fields are reset. The user’s text is lost, unless you return it and use it as defaultValue.
How do you show an error from a form Action?
For errors the user can fix, return them as part of the state from useActionState. Show them next to the form. For bugs you don’t expect, throw. React then shows the nearest error boundary. A network failure the user can retry is better returned as state too. A strong answer adds one more thing. When a useActionState action throws, React also cancels the calls still waiting in line.
How do you stop a form from being sent twice?
Turn the submit button off while the action runs: disabled={pending} from useFormStatus in a child, or disabled={isPending} from useActionState. A strong answer adds that the server should still be safe if a request comes twice. A button that is turned off doesn’t stop a second browser tab, for example.
Sources
<form>, react.dev: theactionprop, “callinge.preventDefault()isn’t needed”, the reset of uncontrolled fields, errors and error boundaries,formAction, Server Functions and forms without JavaScript.- useActionState, react.dev: what it returns, the previous state as the first argument, calls running in order, “not invoked twice in
<StrictMode>“, returned vs thrown errors (“undefined is not a function”),permalink, the namesreducerActionandactionPayload, and the troubleshooting notes. - useFormStatus, react.dev:
pending,data,method,action, and why it reads only a parent<form>. - useTransition and startTransition, react.dev: Actions as functions run in a Transition, and the rule for state set after
await(“are not marked as Transitions”, “out of order”). <input>, react.dev:formActionon a submit button.- React v19, React blog, 2024: the name “Actions”, the automatic form reset,
requestFormReset,useFormStatusreading the form “as if the form was a Context provider”, and the old nameuseFormState. - ‘use server’, react.dev: what the directive marks, and that a Server Function lets a form be sent before the JavaScript bundle is loaded.
- FormData and Progressive enhancement, MDN: what a
FormDataholds, and the definition quoted above. - The console output, the reset table, the
useFormStatusvalues, the call counts with Strict Mode on, the timing of two quick clicks, the error results and therequestFormResetmessage come from running React 19.3.0 for this post, in jsdom and in headless Chromium.