[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-react-weekly-form-actions-pending-state":44,"search-suggestions":60,"quiz-article-react-weekly-form-actions-pending-state":106},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a08734-5cdd-743d-adec-d6ba65e75fb4","react-weekly-form-actions-pending-state","PRACTICE_QUIZ","Test Yourself — React Form Actions & Pending State","Seven questions on useActionState, useFormStatus, and useOptimistic — the mental model and the one real trap.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,88,92,95,99,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",90,{"slug":66,"name":67,"articles":68},"javascript","Javascript",74,{"slug":70,"name":71,"articles":72},"frontend","Frontend",69,{"slug":74,"name":75,"articles":76},"css","Css",31,{"slug":78,"name":79,"articles":80},"tutorial","Tutorial",20,{"slug":82,"name":83,"articles":84},"typescript","Typescript",12,{"slug":86,"name":87,"articles":84},"performance","Performance",{"slug":89,"name":90,"articles":91},"react","React",11,{"slug":93,"name":94,"articles":51},"browser","Browser",{"slug":96,"name":97,"articles":98},"grammar","Grammar",6,{"slug":100,"name":101,"articles":102},"html","Html",5,{"slug":104,"name":105,"articles":102},"programming","Programming",{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":111,"publishedAt":112,"viewCount":111,"likeCount":19,"commentCount":19,"author":113,"vertical":118,"topic":119,"tags":120,"_count":125,"playground":127,"body":129,"bodyMd":422,"seo":423,"translationGroupId":425,"series":426,"podcastUrl":52,"verticalId":5,"thread":438,"assessments":440,"translations":443,"quiz":445},"01a08734-5c8d-73c5-95d1-e6b4ae366c25","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.","\u002Fmedia\u002Fcovers\u002Freact-weekly-form-actions-pending-state.png",13,"2026-09-12T10:23:52.928Z",{"id":114,"name":115,"username":116,"avatarUrl":52,"headline":117},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":89,"name":90},[121,122,123,124],{"slug":89,"name":90,"color":52},{"slug":66,"name":67,"color":52},{"slug":78,"name":79,"color":52},{"slug":62,"name":63,"color":52},{"assessments":126},1,{"slug":46,"title":128},"React form Actions — pending state playground",{"blocks":130,"version":126},[131,135,138,141,146,149,158,161,164,167,181,184,187,193,196,199,202,205,209,212,216,219,223,226,230,233,236,240,243,247,250,253,257,260,264,267,270,273,276,279,282,285,293,296,304,307,311,314,317,320,324,327,331,334,337,340,343,371,375,378,381,384,387,390,393,401,404,407,410,413,416],{"id":132,"html":133,"type":134},"b1","\u003Cp>You&#39;ve built this submit button before. \u003Ccode>useState(false)\u003C\u002Fcode> for \u003Ccode>isSubmitting\u003C\u002Fcode>, set it \u003Ccode>true\u003C\u002Fcode> at the top of the handler, \u003Ccode>await\u003C\u002Fcode> the request, set it back \u003Ccode>false\u003C\u002Fcode> in a \u003Ccode>finally\u003C\u002Fcode>. It works — until a teammate adds an early \u003Ccode>return\u003C\u002Fcode> inside a validation branch that skips the \u003Ccode>finally\u003C\u002Fcode>, or a request fails in a way your \u003Ccode>catch\u003C\u002Fcode> didn&#39;t anticipate, and now the button is disabled forever. The state and the reality it&#39;s supposed to describe have quietly come apart.\u003C\u002Fp>","paragraph",{"id":136,"html":137,"type":134},"b2","\u003Cp>This is episode three of \u003Cstrong>React Deep Dive\u003C\u002Fstrong>, a weekly series on what React itself decides, not JavaScript with a React import at the top. The first two episodes covered \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Freact-re-render-vs-remount-what-actually-triggers-each-5fok\">re-render vs. remount\u003C\u002Fa> and \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Freact-compiler-10-what-usememo-you-can-delete-hgm\">what the React Compiler automates\u003C\u002Fa> — useful background, neither required here. This episode is about form \u003Cstrong>Actions\u003C\u002Fstrong>: \u003Ccode>useActionState\u003C\u002Fcode>, \u003Ccode>useFormStatus\u003C\u002Fcode>, and \u003Ccode>useOptimistic\u003C\u002Fcode>, and the specific problem they solve, which isn&#39;t &quot;nicer syntax for async handlers&quot; — it&#39;s removing a piece of state you were maintaining by hand and could get wrong.\u003C\u002Fp>",{"id":139,"html":140,"type":134},"b3","\u003Cp>This article is written against \u003Cstrong>React 19.2\u003C\u002Fstrong> (verified 19.2.8, the current npm release as of this writing) and assumes React 19-era function components and hooks throughout.\u003C\u002Fp>",{"id":142,"html":143,"text":144,"type":145,"level":31},"b4","What You&#39;ll Learn","What You'll Learn","heading",{"id":147,"html":148,"type":134},"b5","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":150,"type":151,"items":152,"ordered":18},"b6","list",[153,154,155,156,157],"Explain why a form Action&#39;s pending state can&#39;t drift out of sync the way a hand-written \u003Ccode>isSubmitting\u003C\u002Fcode> flag can","Use \u003Ccode>useActionState\u003C\u002Fcode> to bundle an action, its result, and its pending flag into one hook","Read pending status in a child component with \u003Ccode>useFormStatus\u003C\u002Fcode>, without prop-drilling a boolean","Layer \u003Ccode>useOptimistic\u003C\u002Fcode> on 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",{"id":159,"html":160,"text":160,"type":145,"level":31},"b7","Who This Is For",{"id":162,"html":163,"type":134},"b8","\u003Cp>You&#39;ve written function components with \u003Ccode>useState\u003C\u002Fcode> and \u003Ccode>useEffect\u003C\u002Fcode>, and you&#39;ve handled at least one form submission with a manual loading flag. No prior exposure to React 19&#39;s Actions is assumed.\u003C\u002Fp>",{"id":165,"html":166,"text":166,"type":145,"level":31},"b9","Table of Contents",{"id":168,"type":151,"items":169,"ordered":18},"b10",[170,171,172,173,174,175,176,177,178,179,180],"\u003Ca href=\"#the-problem-a-pending-state-you-track-by-hand\">The Problem: A Pending State You Track By Hand\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-pending-is-derived-not-stored\">The Mental Model: Pending Is Derived, Not Stored\u003C\u002Fa>","\u003Ca href=\"#stage-1-a-plain-action-on-a-form\">Stage 1: A Plain Action on a \u003Ccode>&lt;form&gt;\u003C\u002Fcode>\u003C\u002Fa>","\u003Ca href=\"#stage-2-useactionstate-for-result--pending\">Stage 2: \u003Ccode>useActionState\u003C\u002Fcode> for Result + Pending\u003C\u002Fa>","\u003Ca href=\"#stage-3-useformstatus-in-a-child-no-prop-drilling\">Stage 3: \u003Ccode>useFormStatus\u003C\u002Fcode> in a Child, No Prop Drilling\u003C\u002Fa>","\u003Ca href=\"#stage-4-useoptimistic-for-instant-feedback\">Stage 4: \u003Ccode>useOptimistic\u003C\u002Fcode> for Instant Feedback\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge Cases and Gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices-when-not-to-reach-for-actions\">Best Practices: When (Not) to Reach for Actions\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat Sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key Takeaways\u003C\u002Fa>",{"id":182,"html":183,"text":183,"type":145,"level":31},"b11","The Problem: A Pending State You Track By Hand",{"id":185,"html":186,"type":134},"b12","\u003Cp>Here&#39;s the version most of us wrote before React 19:\u003C\u002Fp>",{"id":188,"code":189,"type":190,"language":191,"highlight":192},"b13","function ProfileForm() {\n  const [name, setName] = useState(\"\");\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [error, setError] = useState(null);\n\n  async function handleSubmit(e) {\n    e.preventDefault();\n    setIsSubmitting(true);\n    setError(null);\n    try {\n      await saveProfile(name);\n    } catch (err) {\n      setError(err.message);\n    } finally {\n      setIsSubmitting(false); \u002F\u002F easy to forget, easy to skip\n    }\n  }\n\n  return (\n    \u003Cform onSubmit={handleSubmit}>\n      \u003Cinput value={name} onChange={(e) => setName(e.target.value)} \u002F>\n      \u003Cbutton disabled={isSubmitting}>{isSubmitting ? \"Saving…\" : \"Save\"}\u003C\u002Fbutton>\n      {error && \u003Cp role=\"alert\">{error}\u003C\u002Fp>}\n    \u003C\u002Fform>\n  );\n}","code","jsx",[],{"id":194,"html":195,"type":134},"b14","\u003Cp>This isn&#39;t wrong, and for a single form it isn&#39;t even that risky. The bugs show up as the form grows: a second early \u003Ccode>return\u003C\u002Fcode> added months later that bypasses the \u003Ccode>try\u003C\u002Fcode>, a child button that also needs to know \u003Ccode>isSubmitting\u003C\u002Fcode> and now takes it as a prop, a second submit handler that copies this pattern and forgets the \u003Ccode>finally\u003C\u002Fcode>. \u003Ccode>isSubmitting\u003C\u002Fcode> is a fact you&#39;re asserting about the world — &quot;a request is in flight&quot; — and nothing enforces that the assertion stays true. It&#39;s exactly the kind of derived state that&#39;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.\u003C\u002Fp>",{"id":197,"html":198,"text":198,"type":145,"level":31},"b15","The Mental Model: Pending Is Derived, Not Stored",{"id":200,"html":201,"type":134},"b16","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> a function passed to a \u003Ccode>&lt;form&gt;\u003C\u002Fcode>&#39;s \u003Ccode>action\u003C\u002Fcode> prop (or a \u003Ccode>&lt;button&gt;\u003C\u002Fcode>&#39;s \u003Ccode>formAction\u003C\u002Fcode> prop) becomes an \u003Cstrong>Action\u003C\u002Fstrong> — React runs it inside an implicit transition, the same mechanism behind \u003Ccode>startTransition\u003C\u002Fcode>. The pending flag you get back (\u003Ccode>isPending\u003C\u002Fcode> from \u003Ccode>useActionState\u003C\u002Fcode>, or \u003Ccode>pending\u003C\u002Fcode> from \u003Ccode>useFormStatus\u003C\u002Fcode>) is not a \u003Ccode>useState\u003C\u002Fcode> you or React set by hand — it&#39;s derived from whether that transition is currently in flight. It flips \u003Ccode>true\u003C\u002Fcode> the instant the action starts and flips back \u003Ccode>false\u003C\u002Fcode> when the action&#39;s returned state finishes committing, on the success path and the error path alike, because both are just the transition ending. There&#39;s no \u003Ccode>finally\u003C\u002Fcode> to forget, because there&#39;s no manually-set flag to reset.\u003C\u002Fp>",{"id":203,"html":204,"type":134},"b17","\u003Cp>That&#39;s the whole shift: you stop asserting &quot;a request is in flight&quot; with a boolean you maintain, and start asking React &quot;is the transition I handed you still running&quot; — a question it can always answer correctly, because it&#39;s the one running it.\u003C\u002Fp>",{"id":206,"html":207,"text":208,"type":145,"level":31},"b18","Stage 1: A Plain Action on a \u003Ccode>&lt;form&gt;\u003C\u002Fcode>","Stage 1: A Plain Action on a \u003Cform>",{"id":210,"html":211,"type":134},"b19","\u003Cp>The smallest form of this is passing a function directly to \u003Ccode>action\u003C\u002Fcode> — no hook required yet:\u003C\u002Fp>",{"id":213,"code":214,"type":190,"language":191,"highlight":215},"b20","function NewsletterForm() {\n  async function subscribe(formData) {\n    const email = formData.get(\"email\");\n    await api.subscribe(email);\n  }\n\n  return (\n    \u003Cform action={subscribe}>\n      \u003Cinput name=\"email\" type=\"email\" required \u002F>\n      \u003Cbutton type=\"submit\">Subscribe\u003C\u002Fbutton>\n    \u003C\u002Fform>\n  );\n}",[],{"id":217,"html":218,"type":134},"b21","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> the function receives the submitted \u003Ccode>FormData\u003C\u002Fcode> directly — no \u003Ccode>e.preventDefault()\u003C\u002Fcode>, no reading \u003Ccode>e.target.elements\u003C\u002Fcode> by hand. React intercepts the native submit, builds the \u003Ccode>FormData\u003C\u002Fcode>, and calls your function inside a transition. On success, React automatically resets the form&#39;s \u003Cstrong>uncontrolled\u003C\u002Fstrong> fields, mirroring what a plain HTML form submission would have done. This stage has no visible pending state yet — for that, reach for \u003Ccode>useActionState\u003C\u002Fcode>.\u003C\u002Fp>",{"id":220,"html":221,"text":222,"type":145,"level":31},"b22","Stage 2: \u003Ccode>useActionState\u003C\u002Fcode> for Result + Pending","Stage 2: useActionState for Result + Pending",{"id":224,"html":225,"type":134},"b23","\u003Cp>\u003Ccode>useActionState\u003C\u002Fcode> bundles three things a form usually needs — the action&#39;s result, whether it&#39;s still running, and a wrapped version of the action to pass to \u003Ccode>action\u003C\u002Fcode> — into one hook:\u003C\u002Fp>",{"id":227,"code":228,"type":190,"language":191,"highlight":229},"b24","import { useActionState } from \"react\";\n\nasync function saveName(previousState, formData) {\n  const name = formData.get(\"name\");\n  if (!name.trim()) {\n    return { error: \"Name can't be empty\" }; \u002F\u002F returned, not thrown\n  }\n  await api.saveName(name);\n  return { error: null };\n}\n\nfunction ProfileForm() {\n  const [state, formAction, isPending] = useActionState(saveName, { error: null });\n\n  return (\n    \u003Cform action={formAction}>\n      \u003Cinput name=\"name\" \u002F>\n      \u003Cbutton disabled={isPending}>{isPending ? \"Saving…\" : \"Save\"}\u003C\u002Fbutton>\n      {state.error && \u003Cp role=\"alert\">{state.error}\u003C\u002Fp>}\n    \u003C\u002Fform>\n  );\n}",[],{"id":231,"html":232,"type":134},"b25","\u003Cp>The signature is \u003Ccode>useActionState(fn, initialState, permalink?)\u003C\u002Fcode>, returning \u003Ccode>[state, formAction, isPending]\u003C\u002Fcode>. \u003Ccode>fn\u003C\u002Fcode> receives \u003Ccode>(previousState, formData)\u003C\u002Fcode> — the previous return value is threaded back in automatically, which is exactly the &quot;give me the last result&quot; pattern a reducer gives you, applied to an async action. \u003Ccode>state\u003C\u002Fcode> is \u003Ccode>initialState\u003C\u002Fcode> until the action has run once, then it&#39;s whatever \u003Ccode>fn\u003C\u002Fcode> last returned. \u003Ccode>formAction\u003C\u002Fcode> is what you pass to the form&#39;s \u003Ccode>action\u003C\u002Fcode> prop; calling the raw \u003Ccode>saveName\u003C\u002Fcode> directly wouldn&#39;t give you \u003Ccode>isPending\u003C\u002Fcode> or the threaded \u003Ccode>previousState\u003C\u002Fcode>.\u003C\u002Fp>",{"id":234,"html":235,"type":134},"b26","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> the error here is \u003Cem>returned\u003C\u002Fem>, not thrown. That distinction matters enough to get its own section below — it&#39;s the one real trap in this whole feature.\u003C\u002Fp>",{"id":237,"html":238,"text":239,"type":145,"level":31},"b27","Stage 3: \u003Ccode>useFormStatus\u003C\u002Fcode> in a Child, No Prop Drilling","Stage 3: useFormStatus in a Child, No Prop Drilling",{"id":241,"html":242,"type":134},"b28","\u003Cp>A submit button that needs to know &quot;is my parent form pending&quot; doesn&#39;t have to receive that as a prop:\u003C\u002Fp>",{"id":244,"code":245,"type":190,"language":191,"highlight":246},"b29","import { useFormStatus } from \"react-dom\";\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus();\n  return \u003Cbutton disabled={pending}>{pending ? \"Saving…\" : \"Save\"}\u003C\u002Fbutton>;\n}\n\nfunction ProfileForm() {\n  const [state, formAction] = useActionState(saveName, { error: null });\n  return (\n    \u003Cform action={formAction}>\n      \u003Cinput name=\"name\" \u002F>\n      \u003CSubmitButton \u002F>\n    \u003C\u002Fform>\n  );\n}",[],{"id":248,"html":249,"type":134},"b30","\u003Cp>\u003Ccode>useFormStatus\u003C\u002Fcode> reads status from the nearest enclosing \u003Ccode>&lt;form&gt;\u003C\u002Fcode> through context — \u003Ccode>{ pending, data, method, action }\u003C\u002Fcode>. \u003Cstrong>The one rule that trips people up:\u003C\u002Fstrong> the component calling it must be a \u003Cem>descendant\u003C\u002Fem> of the \u003Ccode>&lt;form&gt;\u003C\u002Fcode>, never the same component that renders the form. Call it inside \u003Ccode>ProfileForm\u003C\u002Fcode> itself and \u003Ccode>pending\u003C\u002Fcode> is always \u003Ccode>false\u003C\u002Fcode>, because from \u003Ccode>&lt;form&gt;\u003C\u002Fcode>&#39;s own perspective there&#39;s no enclosing form to read.\u003C\u002Fp>",{"id":251,"html":252,"type":134},"b31","\u003Cp>This is the payoff for a design system: a \u003Ccode>&lt;SubmitButton&gt;\u003C\u002Fcode> component that works inside \u003Cem>any\u003C\u002Fem> form, with zero props, because the status lives in context rather than being threaded down by hand.\u003C\u002Fp>",{"id":254,"html":255,"text":256,"type":145,"level":31},"b32","Stage 4: \u003Ccode>useOptimistic\u003C\u002Fcode> for Instant Feedback","Stage 4: useOptimistic for Instant Feedback",{"id":258,"html":259,"type":134},"b33","\u003Cp>Layered on top, \u003Ccode>useOptimistic(actualState, updateFn?)\u003C\u002Fcode> returns \u003Ccode>[optimisticState, addOptimistic]\u003C\u002Fcode> — a value that snaps back to \u003Ccode>actualState\u003C\u002Fcode> once the surrounding action settles, letting the UI update the instant the user acts rather than waiting for the network:\u003C\u002Fp>",{"id":261,"code":262,"type":190,"language":191,"highlight":263},"b34","function LikeButton({ postId, likedByMe, likeCount }) {\n  const [optimisticLiked, setOptimisticLiked] = useOptimistic(likedByMe);\n\n  async function toggleLike() {\n    setOptimisticLiked(!optimisticLiked); \u002F\u002F must run inside a transition\n    await api.toggleLike(postId);\n  }\n\n  return (\n    \u003Cbutton onClick={() => startTransition(toggleLike)}>\n      {optimisticLiked ? \"♥ Liked\" : \"♡ Like\"} ({likeCount})\n    \u003C\u002Fbutton>\n  );\n}",[],{"id":265,"html":266,"type":134},"b35","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> \u003Ccode>setOptimisticLiked\u003C\u002Fcode> only has an effect while called inside a transition (an Action, or an explicit \u003Ccode>startTransition\u003C\u002Fcode>) — outside one, it&#39;s a no-op that just re-renders with the real state. If \u003Ccode>toggleLike\u003C\u002Fcode> fails and never updates the real \u003Ccode>likedByMe\u003C\u002Fcode>, the optimistic value reverts on its own once the transition ends; you don&#39;t manually roll it back the way you would with a hand-rolled optimistic update.\u003C\u002Fp>",{"id":268,"html":269,"type":134},"b36","\u003C!-- playground:start -->",{"id":271,"html":272,"text":272,"type":145,"level":31},"b37","🎮 Try it yourself",{"id":274,"html":275,"type":134},"b38","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Freact-weekly-form-actions-pending-state\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":277,"html":278,"type":134},"b39","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":280,"html":281,"type":134},"b40","\u003C!-- playground:end -->",{"id":283,"html":284,"text":284,"type":145,"level":31},"b41","Edge Cases and Gotchas",{"id":286,"type":151,"items":287,"ordered":18},"b42",[288,289,290,291,292],"\u003Cstrong>Throwing vs. returning an error is not a style choice.\u003C\u002Fstrong> 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, \u003Ccode>isPending\u003C\u002Fcode> included. \u003Ccode>saveName\u003C\u002Fcode> in Stage 2 returns \u003Ccode>{ error }\u003C\u002Fcode> precisely to avoid this — catch inside the action, and hand the failure back as state.","\u003Cstrong>Auto-reset only touches uncontrolled fields.\u003C\u002Fstrong> After a successful Action, React resets uncontrolled inputs (no \u003Ccode>value\u003C\u002Fcode>\u002F\u003Ccode>onChange\u003C\u002Fcode>) the way a native form submission would. Controlled inputs are yours to manage — they won&#39;t be touched, so a controlled field&#39;s value persists unless you clear it yourself. If you need to force a reset (including of controlled state), \u003Ccode>react-dom\u003C\u002Fcode>&#39;s \u003Ccode>requestFormReset(formElement)\u003C\u002Fcode> is the escape hatch.","\u003Cstrong>\u003Ccode>useFormStatus\u003C\u002Fcode> walks the component tree, not the DOM.\u003C\u002Fstrong> It resolves the nearest \u003Ccode>&lt;form&gt;\u003C\u002Fcode> your component is rendered under in JSX, not whichever form the DOM engine would associate a button with. A button rendered outside the \u003Ccode>&lt;form&gt;\u003C\u002Fcode> element via a portal, for instance, won&#39;t see that form&#39;s status.","\u003Cstrong>A pending Action doesn&#39;t pause the rest of the UI.\u003C\u002Fstrong> Because it runs in a transition, other state updates and navigation stay responsive while it&#39;s in flight — this is React&#39;s general &quot;keep the UI interactive&quot; behavior for transitions, not something specific to forms.","\u003Cstrong>This is client-side pending state, not automatic request deduplication.\u003C\u002Fstrong> \u003Ccode>isPending\u003C\u002Fcode> correctly reflects whether the transition is running; it doesn&#39;t by itself stop a form from being submitted a second time before your UI re-renders with the disabled button. Disabling the trigger while \u003Ccode>pending\u003C\u002Fcode>\u002F\u003Ccode>isPending\u003C\u002Fcode> is \u003Ccode>true\u003C\u002Fcode>, as every example above does, is still your job — Actions just guarantee that flag is trustworthy.",{"id":294,"html":295,"text":295,"type":145,"level":31},"b43","Best Practices: When (Not) to Reach for Actions",{"id":297,"type":151,"items":298,"ordered":18},"b44",[299,300,301,302,303],"\u003Cstrong>Reach for \u003Ccode>useActionState\u003C\u002Fcode>\u003C\u002Fstrong> whenever a form has a result to show (success message, validation error, saved data) and a pending state to disable during. It&#39;s a strict improvement over hand-rolled \u003Ccode>useState\u003C\u002Fcode> + \u003Ccode>try\u002Fcatch\u002Ffinally\u003C\u002Fcode> for that exact shape.","\u003Cstrong>Reach for \u003Ccode>useFormStatus\u003C\u002Fcode>\u003C\u002Fstrong> the moment a submit control needs pending status and isn&#39;t the component that owns the form — a shared \u003Ccode>&lt;SubmitButton&gt;\u003C\u002Fcode>, a spinner nested a level down, a disabled fieldset.","\u003Cstrong>Reach for \u003Ccode>useOptimistic\u003C\u002Fcode>\u003C\u002Fstrong> when 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).","\u003Cstrong>Don&#39;t reach for any of this\u003C\u002Fstrong> for state that has nothing to do with a pending async operation — a controlled input&#39;s live value, a modal&#39;s open\u002Fclosed flag, a filter. Actions solve the async-pending problem specifically; they aren&#39;t a general replacement for \u003Ccode>useState\u003C\u002Fcode>.","\u003Cstrong>Always return errors from the action\u003C\u002Fstrong>, never throw across the transition boundary, unless you deliberately want the nearest error boundary to catch it.",{"id":305,"html":306,"text":306,"type":145,"level":31},"b45","FAQ",{"id":308,"html":309,"text":310,"type":145,"level":43},"b46","Do I need a server or Next.js to use \u003Ccode>useActionState\u003C\u002Fcode>?","Do I need a server or Next.js to use useActionState?",{"id":312,"html":313,"type":134},"b47","\u003Cp>No. Everything in this article runs with plain client-side functions — \u003Ccode>saveName\u003C\u002Fcode>, \u003Ccode>subscribe\u003C\u002Fcode>, \u003Ccode>toggleLike\u003C\u002Fcode> are ordinary async functions, no \u003Ccode>&quot;use server&quot;\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":315,"html":316,"text":316,"type":145,"level":43},"b48","Does the form really reset itself after I submit?",{"id":318,"html":319,"type":134},"b49","\u003Cp>Only its uncontrolled fields, and only after the action&#39;s promise resolves without the action itself preventing it. Controlled fields (anything with \u003Ccode>value\u003C\u002Fcode> tied to state) are unaffected — you&#39;re already the one deciding what they show.\u003C\u002Fp>",{"id":321,"html":322,"text":323,"type":145,"level":43},"b50","What&#39;s the difference between \u003Ccode>useActionState\u003C\u002Fcode>&#39;s \u003Ccode>isPending\u003C\u002Fcode> and \u003Ccode>useFormStatus\u003C\u002Fcode>&#39;s \u003Ccode>pending\u003C\u002Fcode>?","What's the difference between useActionState's isPending and useFormStatus's pending?",{"id":325,"html":326,"type":134},"b51","\u003Cp>They describe the same underlying transition. \u003Ccode>useActionState\u003C\u002Fcode>&#39;s \u003Ccode>isPending\u003C\u002Fcode> is available in the component that owns the form and called the hook. \u003Ccode>useFormStatus\u003C\u002Fcode>&#39;s \u003Ccode>pending\u003C\u002Fcode> is for a descendant component that doesn&#39;t have access to that hook&#39;s return value and would otherwise need it prop-drilled down.\u003C\u002Fp>",{"id":328,"html":329,"text":330,"type":145,"level":43},"b52","Can \u003Ccode>useOptimistic\u003C\u002Fcode> be used outside a form?","Can useOptimistic be used outside a form?",{"id":332,"html":333,"type":134},"b53","\u003Cp>Yes — nothing about it is form-specific. The like-button example above never touches a \u003Ccode>&lt;form&gt;\u003C\u002Fcode>; it just needs the state update wrapped in a transition (an Action or \u003Ccode>startTransition\u003C\u002Fcode>) to take effect.\u003C\u002Fp>",{"id":335,"html":336,"text":336,"type":145,"level":43},"b54","What happens if two Actions on the same form fire close together?",{"id":338,"html":339,"type":134},"b55","\u003Cp>Each call to the wrapped \u003Ccode>formAction\u003C\u002Fcode> starts its own transition; React processes them in the order they were dispatched, threading each \u003Ccode>previousState\u003C\u002Fcode> from the prior one. In practice, keep the trigger disabled while \u003Ccode>pending\u003C\u002Fcode>\u002F\u003Ccode>isPending\u003C\u002Fcode> is \u003Ccode>true\u003C\u002Fcode> (every example above does this) so a second dispatch normally can&#39;t start until the first finishes.\u003C\u002Fp>",{"id":341,"html":342,"text":342,"type":145,"level":31},"b56","Cheat Sheet",{"id":344,"head":345,"rows":349,"type":370},"b57",[346,347,348],"Need","API","Returns",[350,354,358,362,366],[351,352,353],"Result + pending flag for a form","\u003Ccode>useActionState(fn, initialState, permalink?)\u003C\u002Fcode>","\u003Ccode>[state, formAction, isPending]\u003C\u002Fcode>",[355,356,357],"Pending status in a child, no props","\u003Ccode>useFormStatus()\u003C\u002Fcode> (from \u003Ccode>react-dom\u003C\u002Fcode>)","\u003Ccode>{ pending, data, method, action }\u003C\u002Fcode> — caller must be a \u003Cstrong>descendant\u003C\u002Fstrong> of the \u003Ccode>&lt;form&gt;\u003C\u002Fcode>",[359,360,361],"Instant UI update tied to a transition","\u003Ccode>useOptimistic(actualState, updateFn?)\u003C\u002Fcode>","\u003Ccode>[optimisticState, addOptimistic]\u003C\u002Fcode>",[363,364,365],"Force a form reset (incl. controlled state)","\u003Ccode>requestFormReset(formElement)\u003C\u002Fcode> (from \u003Ccode>react-dom\u003C\u002Fcode>)","—",[367,368,369],"Report a submission failure","\u003Ccode>return { error }\u003C\u002Fcode> from the action","— never \u003Ccode>throw\u003C\u002Fcode>, or the nearest error boundary catches it","table",{"id":372,"code":373,"type":190,"language":191,"highlight":374},"b58","import { useActionState } from \"react\";\nimport { useFormStatus } from \"react-dom\";\n\nasync function action(prevState, formData) {\n  try {\n    await doWork(formData.get(\"field\"));\n    return { error: null };\n  } catch (err) {\n    return { error: err.message }; \u002F\u002F return, don't throw\n  }\n}\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus(); \u002F\u002F must be a child of \u003Cform>\n  return \u003Cbutton disabled={pending}>{pending ? \"Working…\" : \"Submit\"}\u003C\u002Fbutton>;\n}\n\nfunction MyForm() {\n  const [state, formAction] = useActionState(action, { error: null });\n  return (\n    \u003Cform action={formAction}>\n      \u003Cinput name=\"field\" \u002F>\n      \u003CSubmitButton \u002F>\n      {state.error && \u003Cp role=\"alert\">{state.error}\u003C\u002Fp>}\n    \u003C\u002Fform>\n  );\n}",[],{"id":376,"html":377,"type":134},"b59","\u003C!-- quiz:start -->",{"id":379,"html":380,"text":380,"type":145,"level":31},"b60","🧠 Test yourself",{"id":382,"html":383,"type":134},"b61","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Freact-weekly-form-actions-pending-state\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":385,"html":386,"type":134},"b62","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":388,"html":389,"type":134},"b63","\u003C!-- quiz:end -->",{"id":391,"html":392,"text":392,"type":145,"level":31},"b64","Key Takeaways",{"id":394,"type":151,"items":395,"ordered":18},"b65",[396,397,398,399,400],"A form Action&#39;s pending flag is derived from a transition, not a \u003Ccode>useState\u003C\u002Fcode> you maintain — it can&#39;t be left \u003Ccode>true\u003C\u002Fcode> by a missed \u003Ccode>finally\u003C\u002Fcode>, because there&#39;s no manual reset to miss.","\u003Ccode>useActionState(fn, initialState)\u003C\u002Fcode> returns \u003Ccode>[state, formAction, isPending]\u003C\u002Fcode>; \u003Ccode>fn\u003C\u002Fcode> gets \u003Ccode>(previousState, formData)\u003C\u002Fcode> and its return value becomes the next \u003Ccode>state\u003C\u002Fcode>.","\u003Ccode>useFormStatus()\u003C\u002Fcode> only sees a form&#39;s status from a descendant component — never from the component that renders the \u003Ccode>&lt;form&gt;\u003C\u002Fcode> 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.",{"id":402,"html":403,"type":134},"b66","\u003Cp>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 \u003Ccode>isPending\u003C\u002Fcode> to something that&#39;s always accurate: whether the transition you started is still running. The \u003Ccode>try\u003C\u002Fcode>\u002F\u003Ccode>catch\u003C\u002Fcode>\u002F\u003Ccode>finally\u003C\u002Fcode> scaffolding doesn&#39;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).\u003C\u002Fp>",{"id":405,"html":406,"type":134},"b67","\u003Cp>Have you moved a form over to \u003Ccode>useActionState\u003C\u002Fcode> yet, and did the &quot;return the error, don&#39;t throw it&quot; rule bite you the first time? Tell me in the comments.\u003C\u002Fp>",{"id":408,"type":409},"b68","divider",{"id":411,"html":412,"type":134},"b69","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":414,"html":415,"type":134},"b70","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":417,"type":151,"items":418,"ordered":18},"b71",[419,420,421],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","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.\n\nThis 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](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Freact-re-render-vs-remount-what-actually-triggers-each-5fok) and [what the React Compiler automates](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Freact-compiler-10-what-usememo-you-can-delete-hgm) — 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.\n\nThis 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.\n\n## What You'll Learn\n\nBy the end of this article you'll be able to:\n\n- Explain why a form Action's pending state can't drift out of sync the way a hand-written `isSubmitting` flag can\n- Use `useActionState` to bundle an action, its result, and its pending flag into one hook\n- Read pending status in a child component with `useFormStatus`, without prop-drilling a boolean\n- Layer `useOptimistic` on top for instant UI feedback while an action is in flight\n- Avoid the one real trap: throwing inside an action instead of returning an error as state\n\n## Who This Is For\n\nYou'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.\n\n## Table of Contents\n\n- [The Problem: A Pending State You Track By Hand](#the-problem-a-pending-state-you-track-by-hand)\n- [The Mental Model: Pending Is Derived, Not Stored](#the-mental-model-pending-is-derived-not-stored)\n- [Stage 1: A Plain Action on a `\u003Cform>`](#stage-1-a-plain-action-on-a-form)\n- [Stage 2: `useActionState` for Result + Pending](#stage-2-useactionstate-for-result--pending)\n- [Stage 3: `useFormStatus` in a Child, No Prop Drilling](#stage-3-useformstatus-in-a-child-no-prop-drilling)\n- [Stage 4: `useOptimistic` for Instant Feedback](#stage-4-useoptimistic-for-instant-feedback)\n- [Edge Cases and Gotchas](#edge-cases-and-gotchas)\n- [Best Practices: When (Not) to Reach for Actions](#best-practices-when-not-to-reach-for-actions)\n- [FAQ](#faq)\n- [Cheat Sheet](#cheat-sheet)\n- [Key Takeaways](#key-takeaways)\n\n## The Problem: A Pending State You Track By Hand\n\nHere's the version most of us wrote before React 19:\n\n```jsx\nfunction ProfileForm() {\n  const [name, setName] = useState(\"\");\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [error, setError] = useState(null);\n\n  async function handleSubmit(e) {\n    e.preventDefault();\n    setIsSubmitting(true);\n    setError(null);\n    try {\n      await saveProfile(name);\n    } catch (err) {\n      setError(err.message);\n    } finally {\n      setIsSubmitting(false); \u002F\u002F easy to forget, easy to skip\n    }\n  }\n\n  return (\n    \u003Cform onSubmit={handleSubmit}>\n      \u003Cinput value={name} onChange={(e) => setName(e.target.value)} \u002F>\n      \u003Cbutton disabled={isSubmitting}>{isSubmitting ? \"Saving…\" : \"Save\"}\u003C\u002Fbutton>\n      {error && \u003Cp role=\"alert\">{error}\u003C\u002Fp>}\n    \u003C\u002Fform>\n  );\n}\n```\n\nThis 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.\n\n## The Mental Model: Pending Is Derived, Not Stored\n\n**The mental model:** a function passed to a `\u003Cform>`'s `action` prop (or a `\u003Cbutton>`'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.\n\nThat'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.\n\n## Stage 1: A Plain Action on a `\u003Cform>`\n\nThe smallest form of this is passing a function directly to `action` — no hook required yet:\n\n```jsx\nfunction NewsletterForm() {\n  async function subscribe(formData) {\n    const email = formData.get(\"email\");\n    await api.subscribe(email);\n  }\n\n  return (\n    \u003Cform action={subscribe}>\n      \u003Cinput name=\"email\" type=\"email\" required \u002F>\n      \u003Cbutton type=\"submit\">Subscribe\u003C\u002Fbutton>\n    \u003C\u002Fform>\n  );\n}\n```\n\n**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`.\n\n## Stage 2: `useActionState` for Result + Pending\n\n`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:\n\n```jsx\nimport { useActionState } from \"react\";\n\nasync function saveName(previousState, formData) {\n  const name = formData.get(\"name\");\n  if (!name.trim()) {\n    return { error: \"Name can't be empty\" }; \u002F\u002F returned, not thrown\n  }\n  await api.saveName(name);\n  return { error: null };\n}\n\nfunction ProfileForm() {\n  const [state, formAction, isPending] = useActionState(saveName, { error: null });\n\n  return (\n    \u003Cform action={formAction}>\n      \u003Cinput name=\"name\" \u002F>\n      \u003Cbutton disabled={isPending}>{isPending ? \"Saving…\" : \"Save\"}\u003C\u002Fbutton>\n      {state.error && \u003Cp role=\"alert\">{state.error}\u003C\u002Fp>}\n    \u003C\u002Fform>\n  );\n}\n```\n\nThe 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`.\n\n**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.\n\n## Stage 3: `useFormStatus` in a Child, No Prop Drilling\n\nA submit button that needs to know \"is my parent form pending\" doesn't have to receive that as a prop:\n\n```jsx\nimport { useFormStatus } from \"react-dom\";\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus();\n  return \u003Cbutton disabled={pending}>{pending ? \"Saving…\" : \"Save\"}\u003C\u002Fbutton>;\n}\n\nfunction ProfileForm() {\n  const [state, formAction] = useActionState(saveName, { error: null });\n  return (\n    \u003Cform action={formAction}>\n      \u003Cinput name=\"name\" \u002F>\n      \u003CSubmitButton \u002F>\n    \u003C\u002Fform>\n  );\n}\n```\n\n`useFormStatus` reads status from the nearest enclosing `\u003Cform>` through context — `{ pending, data, method, action }`. **The one rule that trips people up:** the component calling it must be a *descendant* of the `\u003Cform>`, never the same component that renders the form. Call it inside `ProfileForm` itself and `pending` is always `false`, because from `\u003Cform>`'s own perspective there's no enclosing form to read.\n\nThis is the payoff for a design system: a `\u003CSubmitButton>` component that works inside *any* form, with zero props, because the status lives in context rather than being threaded down by hand.\n\n## Stage 4: `useOptimistic` for Instant Feedback\n\nLayered 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:\n\n```jsx\nfunction LikeButton({ postId, likedByMe, likeCount }) {\n  const [optimisticLiked, setOptimisticLiked] = useOptimistic(likedByMe);\n\n  async function toggleLike() {\n    setOptimisticLiked(!optimisticLiked); \u002F\u002F must run inside a transition\n    await api.toggleLike(postId);\n  }\n\n  return (\n    \u003Cbutton onClick={() => startTransition(toggleLike)}>\n      {optimisticLiked ? \"♥ Liked\" : \"♡ Like\"} ({likeCount})\n    \u003C\u002Fbutton>\n  );\n}\n```\n\n**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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Freact-weekly-form-actions-pending-state\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## Edge Cases and Gotchas\n\n- **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, `isPending` included. `saveName` in Stage 2 returns `{ error }` precisely to avoid this — catch inside the action, and hand the failure back as state.\n- **Auto-reset only touches uncontrolled fields.** After a successful Action, React resets uncontrolled inputs (no `value`\u002F`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`'s `requestFormReset(formElement)` is the escape hatch.\n- **`useFormStatus` walks the component tree, not the DOM.** It resolves the nearest `\u003Cform>` your component is rendered under in JSX, not whichever form the DOM engine would associate a button with. A button rendered outside the `\u003Cform>` element via a portal, for instance, won't see that form's status.\n- **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.\n- **This is client-side pending state, not automatic request deduplication.** `isPending` correctly 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 while `pending`\u002F`isPending` is `true`, as every example above does, is still your job — Actions just guarantee that flag is trustworthy.\n\n## Best Practices: When (Not) to Reach for Actions\n\n- **Reach for `useActionState`** whenever 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-rolled `useState` + `try\u002Fcatch\u002Ffinally` for that exact shape.\n- **Reach for `useFormStatus`** the moment a submit control needs pending status and isn't the component that owns the form — a shared `\u003CSubmitButton>`, a spinner nested a level down, a disabled fieldset.\n- **Reach for `useOptimistic`** when 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).\n- **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\u002Fclosed flag, a filter. Actions solve the async-pending problem specifically; they aren't a general replacement for `useState`.\n- **Always return errors from the action**, never throw across the transition boundary, unless you deliberately want the nearest error boundary to catch it.\n\n## FAQ\n\n### Do I need a server or Next.js to use `useActionState`?\n\nNo. 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.\n\n### Does the form really reset itself after I submit?\n\nOnly 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.\n\n### What's the difference between `useActionState`'s `isPending` and `useFormStatus`'s `pending`?\n\nThey 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.\n\n### Can `useOptimistic` be used outside a form?\n\nYes — nothing about it is form-specific. The like-button example above never touches a `\u003Cform>`; it just needs the state update wrapped in a transition (an Action or `startTransition`) to take effect.\n\n### What happens if two Actions on the same form fire close together?\n\nEach 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`\u002F`isPending` is `true` (every example above does this) so a second dispatch normally can't start until the first finishes.\n\n## Cheat Sheet\n\n| Need | API | Returns |\n| --- | --- | --- |\n| Result + pending flag for a form | `useActionState(fn, initialState, permalink?)` | `[state, formAction, isPending]` |\n| Pending status in a child, no props | `useFormStatus()` (from `react-dom`) | `{ pending, data, method, action }` — caller must be a **descendant** of the `\u003Cform>` |\n| Instant UI update tied to a transition | `useOptimistic(actualState, updateFn?)` | `[optimisticState, addOptimistic]` |\n| Force a form reset (incl. controlled state) | `requestFormReset(formElement)` (from `react-dom`) | — |\n| Report a submission failure | `return { error }` from the action | — never `throw`, or the nearest error boundary catches it |\n\n```jsx\nimport { useActionState } from \"react\";\nimport { useFormStatus } from \"react-dom\";\n\nasync function action(prevState, formData) {\n  try {\n    await doWork(formData.get(\"field\"));\n    return { error: null };\n  } catch (err) {\n    return { error: err.message }; \u002F\u002F return, don't throw\n  }\n}\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus(); \u002F\u002F must be a child of \u003Cform>\n  return \u003Cbutton disabled={pending}>{pending ? \"Working…\" : \"Submit\"}\u003C\u002Fbutton>;\n}\n\nfunction MyForm() {\n  const [state, formAction] = useActionState(action, { error: null });\n  return (\n    \u003Cform action={formAction}>\n      \u003Cinput name=\"field\" \u002F>\n      \u003CSubmitButton \u002F>\n      {state.error && \u003Cp role=\"alert\">{state.error}\u003C\u002Fp>}\n    \u003C\u002Fform>\n  );\n}\n```\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Freact-weekly-form-actions-pending-state\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n## Key Takeaways\n\n- A form Action's pending flag is derived from a transition, not a `useState` you maintain — it can't be left `true` by a missed `finally`, because there's no manual reset to miss.\n- `useActionState(fn, initialState)` returns `[state, formAction, isPending]`; `fn` gets `(previousState, formData)` and its return value becomes the next `state`.\n- `useFormStatus()` only sees a form's status from a descendant component — never from the component that renders the `\u003Cform>` itself.\n- Return errors from an action; throwing sends the error to the nearest error boundary and unmounts the pending UI instead of resetting it gracefully.\n- Auto-reset after a successful action only clears uncontrolled fields — controlled inputs stay exactly as your state says they should.\n\nThe 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`\u002F`catch`\u002F`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).\n\nHave 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.\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":108,"canonical":424,"description":109},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Freact-weekly-form-actions-pending-state","01a08734-5c8e-7339-b43f-b1dc2c4ab256",{"name":427,"part":43,"total":43,"items":428},"React Deep Dive",[429,433,437],{"slug":430,"title":431,"publishedAt":432,"readingMinutes":111},"react-weekly-rerender-vs-remount","React Re-render vs Remount: What Actually Triggers Each","2026-08-29T12:32:41.200Z",{"slug":434,"title":435,"publishedAt":436,"readingMinutes":111},"react-weekly-compiler-memoization","React Compiler 1.0: What useMemo You Can Delete","2026-09-05T10:17:49.849Z",{"slug":46,"title":108,"publishedAt":112,"readingMinutes":111},{"id":439,"locked":18},"01a08734-5cc1-726b-8878-3e20ad0e73ec",[441],{"id":45,"slug":46,"title":48,"_count":442},{"questions":51},[444],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":446,"questionCount":51},{"questions":51}]