Before React 19, a typical submit handler had five pieces of state: the field values, isSubmitting, error, successMessage, and some logic to stop double submits. Every team wrote it a little differently, and most versions had a bug where a slow first response overwrote a faster second one.
React 19 lets a <form> take a function as its action, and adds useActionState to hold the result of that function between submissions. The hook replaces most of the hand-rolled state, but it has a few behaviours that surprise people the first time, and those are exactly what interview questions target.
The shape of the hook
const [state, formAction, isPending] = useActionState(action, initialState);action(previousState, formData)is your function. It can be async. Whatever it returns (or resolves to) becomes the newstate.formActionis what you pass to<form action={formAction}>(or to a button’sformAction). React calls youractionwith the current state and the form’sFormData.isPendingistruewhile the action is running.
The first argument being the previous state is the part that trips people up when converting an existing function. A server function that used to take (formData) now takes (prevState, formData), and forgetting the extra parameter means formData is actually the previous state object, so formData.get is not a function.
A worked example: a newsletter signup
import { useActionState } from 'react';
async function subscribe(prev, formData) {
const email = String(formData.get('email') ?? '').trim();
if (!email.includes('@')) {
return { attempts: prev.attempts + 1, error: 'Enter a valid email address.', email };
}
const res = await fetch('/api/subscribe', { method: 'POST', body: JSON.stringify({ email }) });
if (!res.ok) {
return { attempts: prev.attempts + 1, error: 'We could not subscribe you. Try again.', email };
}
return { attempts: prev.attempts + 1, error: null, email: '', message: `Subscribed ${email}` };
}
export function Newsletter() {
const [state, formAction, isPending] = useActionState(subscribe, {
attempts: 0, error: null, email: '',
});
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={state.email}
aria-invalid={state.error ? 'true' : undefined}
aria-describedby={state.error ? 'email-error' : undefined}
/>
<button disabled={isPending}>{isPending ? 'Subscribing…' : 'Subscribe'}</button>
{state.error && <p id="email-error" role="alert">{state.error}</p>}
{state.message && <p role="status">{state.message}</p>}
</form>
);
}This component was run with React 19 in jsdom, the network call replaced by a 30 ms delay, and submitted twice: first ada.example.com, then ada@example.com. The action was called with:
action(prev.attempts=0, email="ada.example.com")
action(prev.attempts=1, email="ada@example.com")and the component rendered in this order:
isPending=false error=null msg=
isPending=true error=null msg=
isPending=false error=Enter a valid email address. msg=
isPending=true error=Enter a valid email address. msg=
isPending=false error=null msg=Subscribed ada@example.comWalk through it. While the first action ran, the button read “Subscribing…” and was disabled. When it returned, the error object became state, so the alert rendered. On the second submission the previous error stayed visible while pending (state only changes when the action returns), then the success state replaced it. prev.attempts went from 0 to 1, showing that each call receives the result of the one before.
Errors are returned, not thrown. A thrown error from a form action propagates to the nearest error boundary, which replaces the form. Expected failures (invalid input, a 409 from the server) belong in state so the user can fix them in place.
The form reset that clears failed input
Here’s the behaviour that catches nearly everyone. After a form action completes, React 19 resets the form’s uncontrolled fields, as a native form submission would. It does that whether your action succeeded or returned an error.
In the same test, with defaultValue={state.email} removed from the input, submitting ada.example.com showed the error message and an empty input. The user typed something slightly wrong and lost all of it. With defaultValue={state.email} restored, the reset puts the field back to whatever the action returned, so after the invalid attempt the input read ada.example.com again, and after the successful one it was empty because the action returned email: ''.
So the pattern is: return the submitted values in your error state and use them as defaultValue. For larger forms, return an object of values keyed by field name. The alternative is to make the inputs controlled with useState, which opts them out of the reset, but then you’re back to maintaining field state by hand.
Pending state in nested components
isPending lives in the component that called the hook. A shared SubmitButton component deeper in the tree can’t see it without props. That’s what useFormStatus from react-dom is for:
import { useFormStatus } from 'react-dom';
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving…' : children}</button>;
}useFormStatus reads the status of the nearest parent <form>, so it must be rendered inside the form, not in the component that renders the form.
Ordering and double submits
Dispatches through one useActionState hook are queued: if the user manages to submit twice, the second call waits for the first and receives its result as previousState. That removes the out-of-order overwrite bug, but it also means a slow first request delays the second. Disabling the button while pending is still good practice; it signals what’s happening and avoids sending a request the user didn’t mean to send twice.
The queue only covers this hook instance. It doesn’t make your server idempotent. If submitting twice must not create two orders, the server needs an idempotency key or a uniqueness constraint.
Calling the action without a form
You can call formAction yourself, for example from a keyboard shortcut, but it has to run inside a transition. Call it directly from an onClick and React warns: “An async function with useActionState was called outside of a transition”, and isPending won’t update. Wrap the call in startTransition(() => formAction(data)). Passing it to a <form action> or a button’s formAction prop does this wrapping for you, which is one more reason to prefer the form.
Server functions and progressive enhancement
In a framework with Server Functions (such as the Next.js App Router), the action can be a 'use server' function. React then sends the form data to the server, and the third argument, useActionState(action, initialState, permalink), lets the form submit to a real URL before JavaScript has loaded. That’s progressive enhancement: the form works on a slow connection while the bundle is still downloading.
Whichever way you run it, the validation in the client-side example is a courtesy. The server function or API endpoint must validate the same rules, because anyone can post to it directly.
Interview exercise: the profile form that loses data
“Our profile form uses useActionState. When the server rejects the display name as taken, the error message appears, but every field, including the bio the user spent five minutes on, is blank. How do you fix it?”
Answer. The fields are uncontrolled, and React 19 resets a form’s uncontrolled fields after the action completes, regardless of whether it returned an error. The action is returning only the error message, so the reset goes back to the original default values, which are empty. I’d have the action return the submitted values along with field-specific errors, for example { values: { displayName, bio }, errors: { displayName: 'That name is taken' } }, and set each input’s defaultValue from state.values, so the reset restores what the user typed. I’d show the error next to the display name field with aria-describedby and move focus to it. If one field needs live behaviour, like a character counter on the bio, I’d make just that field controlled. I’d also double-check the action signature takes (prevState, formData) and that the server validates the name again rather than relying on any client check.