React Form Actions: useActionState & useFormStatus Guide
How React 19 form Actions manage pending state automatically — useActionState, useFormStatus, and useOptimistic explained with the bug they replace.

- 1React Re-render vs Remount: What Actually Triggers Each13 min
- 2React Compiler 1.0: What useMemo You Can Delete13 min
- 3React Form Actions: useActionState & useFormStatus Guideyou are here
You've built this submit button before. useState(false) for isSubmitting, set it true at the top of the handler, await the request, set it back false in a finally. It works — until a teammate adds an early return inside a validation branch that skips the finally, or a request fails in a way your catch didn't anticipate, and now the button is disabled forever. The state and the reality it's supposed to describe have quietly come apart.
This is episode three of React Deep Dive, a weekly series on what React itself decides, not JavaScript with a React import at the top. The first two episodes covered re-render vs. remount and what the React Compiler automates — useful background, neither required here. This episode is about form Actions: useActionState, useFormStatus, and useOptimistic, and the specific problem they solve, which isn't "nicer syntax for async handlers" — it's removing a piece of state you were maintaining by hand and could get wrong.
This article is written against React 19.2 (verified 19.2.8, the current npm release as of this writing) and assumes React 19-era function components and hooks throughout.
By the end of this article you'll be able to:
- Explain why a form Action's pending state can't drift out of sync the way a hand-written
isSubmittingflag can - Use
useActionStateto bundle an action, its result, and its pending flag into one hook - Read pending status in a child component with
useFormStatus, without prop-drilling a boolean - Layer
useOptimisticon top for instant UI feedback while an action is in flight - Avoid the one real trap: throwing inside an action instead of returning an error as state
You've written function components with useState and useEffect, and you've handled at least one form submission with a manual loading flag. No prior exposure to React 19's Actions is assumed.
- The Problem: A Pending State You Track By Hand
- The Mental Model: Pending Is Derived, Not Stored
- Stage 1: A Plain Action on a
<form> - Stage 2:
useActionStatefor Result + Pending - Stage 3:
useFormStatusin a Child, No Prop Drilling - Stage 4:
useOptimisticfor Instant Feedback - Edge Cases and Gotchas
- Best Practices: When (Not) to Reach for Actions
- FAQ
- Cheat Sheet
- Key Takeaways
Here's the version most of us wrote before React 19:
This isn't wrong, and for a single form it isn't even that risky. The bugs show up as the form grows: a second early return added months later that bypasses the try, a child button that also needs to know isSubmitting and now takes it as a prop, a second submit handler that copies this pattern and forgets the finally. isSubmitting is a fact you're asserting about the world — "a request is in flight" — and nothing enforces that the assertion stays true. It's exactly the kind of derived state that's easy to let rot, the same category of bug as storing a value in state when you could have computed it from something else.
The mental model: a function passed to a <form>'s action prop (or a <button>'s formAction prop) becomes an Action — React runs it inside an implicit transition, the same mechanism behind startTransition. The pending flag you get back (isPending from useActionState, or pending from useFormStatus) is not a useState you or React set by hand — it's derived from whether that transition is currently in flight. It flips true the instant the action starts and flips back false when the action's returned state finishes committing, on the success path and the error path alike, because both are just the transition ending. There's no finally to forget, because there's no manually-set flag to reset.
That's the whole shift: you stop asserting "a request is in flight" with a boolean you maintain, and start asking React "is the transition I handed you still running" — a question it can always answer correctly, because it's the one running it.
The smallest form of this is passing a function directly to action — no hook required yet:
Key concept: the function receives the submitted FormData directly — no e.preventDefault(), no reading e.target.elements by hand. React intercepts the native submit, builds the FormData, and calls your function inside a transition. On success, React automatically resets the form's uncontrolled fields, mirroring what a plain HTML form submission would have done. This stage has no visible pending state yet — for that, reach for useActionState.
useActionState bundles three things a form usually needs — the action's result, whether it's still running, and a wrapped version of the action to pass to action — into one hook:
The signature is useActionState(fn, initialState, permalink?), returning [state, formAction, isPending]. fn receives (previousState, formData) — the previous return value is threaded back in automatically, which is exactly the "give me the last result" pattern a reducer gives you, applied to an async action. state is initialState until the action has run once, then it's whatever fn last returned. formAction is what you pass to the form's action prop; calling the raw saveName directly wouldn't give you isPending or the threaded previousState.
Key concept: the error here is returned, not thrown. That distinction matters enough to get its own section below — it's the one real trap in this whole feature.
A submit button that needs to know "is my parent form pending" doesn't have to receive that as a prop:
useFormStatus reads status from the nearest enclosing <form> through context — { pending, data, method, action }. The one rule that trips people up: the component calling it must be a descendant of the <form>, never the same component that renders the form. Call it inside ProfileForm itself and pending is always false, because from <form>'s own perspective there's no enclosing form to read.
This is the payoff for a design system: a <SubmitButton> component that works inside any form, with zero props, because the status lives in context rather than being threaded down by hand.
Layered on top, useOptimistic(actualState, updateFn?) returns [optimisticState, addOptimistic] — a value that snaps back to actualState once the surrounding action settles, letting the UI update the instant the user acts rather than waiting for the network:
Key concept: setOptimisticLiked only has an effect while called inside a transition (an Action, or an explicit startTransition) — outside one, it's a no-op that just re-renders with the real state. If toggleLike fails and never updates the real likedByMe, the optimistic value reverts on its own once the transition ends; you don't manually roll it back the way you would with a hand-rolled optimistic update.
Runs right in your browser — poke at it and watch the concept react live.
- Throwing vs. returning an error is not a style choice. If your action function throws instead of returning an error value, React treats that like any other render-phase error inside a transition: it propagates to the nearest error boundary and unmounts the subtree,
isPendingincluded.saveNamein Stage 2 returns{ error }precisely to avoid this — catch inside the action, and hand the failure back as state. - Auto-reset only touches uncontrolled fields. After a successful Action, React resets uncontrolled inputs (no
value/onChange) the way a native form submission would. Controlled inputs are yours to manage — they won't be touched, so a controlled field's value persists unless you clear it yourself. If you need to force a reset (including of controlled state),react-dom'srequestFormReset(formElement)is the escape hatch. useFormStatuswalks the component tree, not the DOM. It resolves the nearest<form>your component is rendered under in JSX, not whichever form the DOM engine would associate a button with. A button rendered outside the<form>element via a portal, for instance, won't see that form's status.- A pending Action doesn't pause the rest of the UI. Because it runs in a transition, other state updates and navigation stay responsive while it's in flight — this is React's general "keep the UI interactive" behavior for transitions, not something specific to forms.
- This is client-side pending state, not automatic request deduplication.
isPendingcorrectly reflects whether the transition is running; it doesn't by itself stop a form from being submitted a second time before your UI re-renders with the disabled button. Disabling the trigger whilepending/isPendingistrue, as every example above does, is still your job — Actions just guarantee that flag is trustworthy.
- Reach for
useActionStatewhenever a form has a result to show (success message, validation error, saved data) and a pending state to disable during. It's a strict improvement over hand-rolleduseState+try/catch/finallyfor that exact shape. - Reach for
useFormStatusthe moment a submit control needs pending status and isn't the component that owns the form — a shared<SubmitButton>, a spinner nested a level down, a disabled fieldset. - Reach for
useOptimisticwhen the action is very likely to succeed and the cost of being visibly wrong for a second is lower than the cost of a spinner — likes, votes, toggles. Skip it for anything where showing the wrong state, even briefly, would mislead the user (payments, destructive actions). - Don't reach for any of this for state that has nothing to do with a pending async operation — a controlled input's live value, a modal's open/closed flag, a filter. Actions solve the async-pending problem specifically; they aren't a general replacement for
useState. - Always return errors from the action, never throw across the transition boundary, unless you deliberately want the nearest error boundary to catch it.
No. Everything in this article runs with plain client-side functions — saveName, subscribe, toggleLike are ordinary async functions, no "use server" directive involved. Server Actions (functions that actually execute on a server, callable from a client component) are a related but separate feature that frameworks like Next.js build on top of this same Actions mechanism.
Only its uncontrolled fields, and only after the action's promise resolves without the action itself preventing it. Controlled fields (anything with value tied to state) are unaffected — you're already the one deciding what they show.
They describe the same underlying transition. useActionState's isPending is available in the component that owns the form and called the hook. useFormStatus's pending is for a descendant component that doesn't have access to that hook's return value and would otherwise need it prop-drilled down.
Yes — nothing about it is form-specific. The like-button example above never touches a <form>; it just needs the state update wrapped in a transition (an Action or startTransition) to take effect.
Each call to the wrapped formAction starts its own transition; React processes them in the order they were dispatched, threading each previousState from the prior one. In practice, keep the trigger disabled while pending/isPending is true (every example above does this) so a second dispatch normally can't start until the first finishes.
| Need | API | Returns |
|---|---|---|
| Result + pending flag for a form | useActionState(fn, initialState, permalink?) | [state, formAction, isPending] |
| Pending status in a child, no props | useFormStatus() (from react-dom) | { pending, data, method, action } — caller must be a descendant of the <form> |
| Instant UI update tied to a transition | useOptimistic(actualState, updateFn?) | [optimisticState, addOptimistic] |
| Force a form reset (incl. controlled state) | requestFormReset(formElement) (from react-dom) | — |
| Report a submission failure | return { error } from the action | — never throw, or the nearest error boundary catches it |
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- A form Action's pending flag is derived from a transition, not a
useStateyou maintain — it can't be lefttrueby a missedfinally, because there's no manual reset to miss. useActionState(fn, initialState)returns[state, formAction, isPending];fngets(previousState, formData)and its return value becomes the nextstate.useFormStatus()only sees a form's status from a descendant component — never from the component that renders the<form>itself.- Return errors from an action; throwing sends the error to the nearest error boundary and unmounts the pending UI instead of resetting it gracefully.
- Auto-reset after a successful action only clears uncontrolled fields — controlled inputs stay exactly as your state says they should.
The button that stayed disabled forever in the opening example was disabled because a human had to remember to turn it back off, and one code path forgot. Actions remove that human step for the pending flag specifically — not by hiding the async work, but by tying isPending to something that's always accurate: whether the transition you started is still running. The try/catch/finally scaffolding doesn't disappear; it moves inside the action, where a missed case fails loudly (a thrown error, caught by a boundary) instead of quietly (a button stuck disabled that nobody notices until support tickets show up).
Have you moved a form over to useActionState yet, and did the "return the error, don't throw it" rule bite you the first time? Tell me in the comments.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.