{"id":508,"date":"2026-07-26T13:03:51","date_gmt":"2026-07-26T13:03:51","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/react-19-useactionstate-the-form-boilerplate-i-deleted\/"},"modified":"2026-07-26T13:03:51","modified_gmt":"2026-07-26T13:03:51","slug":"react-19-useactionstate-the-form-boilerplate-i-deleted","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/react-19-useactionstate-the-form-boilerplate-i-deleted\/","title":{"rendered":"React 19 useActionState: The Form Boilerplate I Deleted"},"content":{"rendered":"<p>I spent an embarrassing amount of time last month deleting code I was proud of.<\/p>\n<p>It was a login form. Nothing fancy. Email, password, a submit button, and the usual pile of state bolted around it: a <code>loading<\/code> boolean, an <code>error<\/code> string, and a submit handler I&rsquo;d rather not show my past self. I&rsquo;ve written that exact pattern maybe forty times across different projects. It&rsquo;s muscle memory now, which is the problem. I stopped noticing how much of it was busywork.<\/p>\n<p>Then I actually sat down with React 19&rsquo;s <code>useActionState<\/code> and <code>useFormStatus<\/code>, and roughly two thirds of that form evaporated. Not refactored into a hook I&rsquo;d have to maintain. Gone. The bookkeeping I&rsquo;d been hand-rolling since 2019 is now the framework&rsquo;s job.<\/p>\n<p>So this is the before and after, plus the parts where the new hooks genuinely annoyed me, because they aren&rsquo;t free. If you&rsquo;ve been avoiding React 19 because the upgrade notes looked like a weekend you didn&rsquo;t have, the forms story alone might earn back the afternoon.<\/p>\n<h2 id=\"the-form-state-i-used-to-write-by-hand\">The form state I used to write by hand<\/h2>\n<p>Here&rsquo;s the React 18 version, more or less how I wrote it for years. Four pieces of state, a handler that has to remember to flip <code>loading<\/code> on and off in the right order, and a <code>try\/catch\/finally<\/code> that I have copy-pasted so many times I could type it asleep.<\/p>\n<pre><code class=\"language-jsx\">function LoginForm() {\n  const [email, setEmail] = useState(&quot;&quot;);\n  const [password, setPassword] = useState(&quot;&quot;);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState(null);\n\n  async function handleSubmit(e) {\n    e.preventDefault();\n    setLoading(true);\n    setError(null);\n    try {\n      await login({ email, password });\n    } catch (err) {\n      setError(err.message);\n    } finally {\n      setLoading(false);\n    }\n  }\n\n  return (\n    &lt;form onSubmit={handleSubmit}&gt;\n      &lt;input value={email} onChange={(e) =&gt; setEmail(e.target.value)} \/&gt;\n      &lt;input\n        type=&quot;password&quot;\n        value={password}\n        onChange={(e) =&gt; setPassword(e.target.value)}\n      \/&gt;\n      {error &amp;&amp; &lt;p className=&quot;error&quot;&gt;{error}&lt;\/p&gt;}\n      &lt;button disabled={loading}&gt;\n        {loading ? &quot;Signing in...&quot; : &quot;Sign in&quot;}\n      &lt;\/button&gt;\n    &lt;\/form&gt;\n  );\n}\n<\/code><\/pre>\n<p>Look at how much of this has nothing to do with logging in. Two controlled inputs that exist only to shuttle characters into state. A <code>finally<\/code> block whose entire job is to undo a thing I did three lines earlier. And the classic bug hiding in plain sight: forget to reset <code>error<\/code> at the top of the handler, and a stale message sticks around from the last failed attempt. I&rsquo;ve shipped that bug. More than once.<\/p>\n<p>The controlled-input dance is the part that always bothered me most. React can already read form values straight off the DOM through <code>FormData<\/code>. But for years the idiomatic answer was to mirror every field into <code>useState<\/code> anyway, so you ended up maintaining a second copy of data the browser was already tracking for you.<\/p>\n<h2 id=\"what-useactionstate-actually-gives-you\">What useActionState actually gives you<\/h2>\n<p>Same form, React 19. The whole thing collapses into one hook call.<\/p>\n<pre><code class=\"language-jsx\">import { useActionState } from &quot;react&quot;;\n\nfunction LoginForm() {\n  const [error, submitAction, isPending] = useActionState(\n    async (previousState, formData) =&gt; {\n      try {\n        await login({\n          email: formData.get(&quot;email&quot;),\n          password: formData.get(&quot;password&quot;),\n        });\n        return null;\n      } catch (err) {\n        return err.message;\n      }\n    },\n    null,\n  );\n\n  return (\n    &lt;form action={submitAction}&gt;\n      &lt;input name=&quot;email&quot; \/&gt;\n      &lt;input name=&quot;password&quot; type=&quot;password&quot; \/&gt;\n      {error &amp;&amp; &lt;p className=&quot;error&quot;&gt;{error}&lt;\/p&gt;}\n      &lt;button disabled={isPending}&gt;\n        {isPending ? &quot;Signing in...&quot; : &quot;Sign in&quot;}\n      &lt;\/button&gt;\n    &lt;\/form&gt;\n  );\n}\n<\/code><\/pre>\n<p>A few things happened here that are worth saying out loud. The inputs are uncontrolled now. They just have a <code>name<\/code>, and the values arrive as <code>formData<\/code> when the form submits. No <code>value<\/code>, no <code>onChange<\/code>, no mirrored state.<\/p>\n<p><code>isPending<\/code> is handed to me. I never set it, never reset it, can&rsquo;t forget the <code>finally<\/code>. React flips it while the action runs and flips it back when the action resolves. That&rsquo;s the whole <code>loading<\/code> boolean, deleted.<\/p>\n<p>And the return value of the action becomes the new state. Return <code>err.message<\/code> and it lands in <code>error<\/code>. Return <code>null<\/code> and the error clears. The stale-error bug I mentioned can&rsquo;t happen, because every submit produces a fresh state instead of me mutating the old one. The React team&rsquo;s own <a href=\"https:\/\/react.dev\/reference\/react\/useActionState\" rel=\"nofollow noopener\" target=\"_blank\">useActionState reference<\/a> frames it as state that&rsquo;s derived from the last submission, and once that clicked for me the whole thing felt obvious.<\/p>\n<p>The signature reads a little strange the first time. The action gets <code>previousState<\/code> as its first argument before <code>formData<\/code>. I ignored it in the login example, but it&rsquo;s genuinely useful. Think of a &ldquo;retry&rdquo; counter, or a wizard where each step needs to see what the last step returned. It&rsquo;s <code>useReducer<\/code> energy, except the dispatch is a form submission.<\/p>\n<p>The other thing that took me a beat: because the returned value is your state, you get to decide its shape. In the login form I return a plain string. In a signup form I return an object with per-field errors, so I can render a message under the specific input that failed. React doesn&rsquo;t care what you return, it just hands it back on the next render. That flexibility is easy to miss when every tutorial returns a string and moves on.<\/p>\n<p>If you want the full picture of why React 19 leaned this way, the <a href=\"https:\/\/react.dev\/blog\/2024\/12\/05\/react-19\" rel=\"nofollow noopener\" target=\"_blank\">React 19 release post<\/a> is the primary source, and it&rsquo;s one of the better-written release notes I&rsquo;ve read.<\/p>\n<h2 id=\"useformstatus-or-how-the-button-knows-its-busy\">useFormStatus, or how the button knows it&rsquo;s busy<\/h2>\n<p>There&rsquo;s a second hook that pairs with this, and it solves an annoyance I&rsquo;d just learned to live with: the submit button usually lives a few components away from the form&rsquo;s state, so wiring <code>pending<\/code> down to it meant prop-drilling or context.<\/p>\n<p><code>useFormStatus<\/code> reads the status of the nearest parent <code>&lt;form&gt;<\/code> directly. No props.<\/p>\n<pre><code class=\"language-jsx\">import { useFormStatus } from &quot;react-dom&quot;;\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus();\n  return (\n    &lt;button disabled={pending}&gt;\n      {pending ? &quot;Signing in...&quot; : &quot;Sign in&quot;}\n    &lt;\/button&gt;\n  );\n}\n<\/code><\/pre>\n<p>Drop <code>&lt;SubmitButton \/&gt;<\/code> anywhere inside the form and it knows when the form is submitting. That&rsquo;s it. The one rule that tripped me up: it has to be a child component. Call <code>useFormStatus<\/code> in the same component that renders the <code>&lt;form&gt;<\/code> and you get <code>pending: false<\/code> forever, because it looks upward for a form and there isn&rsquo;t one above it yet. The <a href=\"https:\/\/react.dev\/reference\/react-dom\/hooks\/useFormStatus\" rel=\"nofollow noopener\" target=\"_blank\">useFormStatus docs<\/a> say this plainly, and I still ignored it for a good twenty minutes before rereading.<\/p>\n<h2 id=\"where-it-bit-me-and-where-i-still-reach-for-usestate\">Where it bit me, and where I still reach for useState<\/h2>\n<p>I don&rsquo;t want to oversell this. A few things cost me time.<\/p>\n<p>Client-side validation gets a bit awkward. With controlled inputs I used to validate on every keystroke. With uncontrolled fields the values live in the DOM until submit, so instant &ldquo;passwords don&rsquo;t match&rdquo; feedback needs a different approach, usually native HTML validation or reading <code>FormData<\/code> in an <code>onChange<\/code> on the form. Not hard, just different, and I fought it before I accepted it.<\/p>\n<p>Actions also swallow errors by design. If your action throws instead of returning, the error bubbles to the nearest error boundary rather than into your <code>error<\/code> state. That&rsquo;s fine if you&rsquo;ve got boundaries set up, which is a habit I&rsquo;d recommend anyway. I wrote about the ones I actually ship over in my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work on frontend reliability<\/a> if you want the setup.<\/p>\n<p>And honestly, <code>useActionState<\/code> isn&rsquo;t the answer for everything. A search box that filters a list as you type is still a <code>useState<\/code> job. Actions shine when there&rsquo;s a discrete submit with a pending window and a result. For live-as-you-type UI, the optimistic-update route fits better, which is exactly the case I made in <a href=\"https:\/\/abrarqasim.com\/blog\/react-19-useoptimistic-loading-spinners-i-finally-deleted\" rel=\"noopener\">the post on useOptimistic<\/a>. Different tool, different problem.<\/p>\n<p>So no, I&rsquo;m not deleting <code>useState<\/code>. I&rsquo;m deleting the specific, repetitive version of it that every form used to demand.<\/p>\n<h2 id=\"what-id-actually-do-this-week\">What I&rsquo;d actually do this week<\/h2>\n<p>Pick your ugliest form. The one with five pieces of state and a handler you&rsquo;ve been afraid to touch. Rewrite just that one with <code>useActionState<\/code>, move the submit button into its own child component with <code>useFormStatus<\/code>, and see how much code disappears.<\/p>\n<p>Don&rsquo;t do a big-bang migration. React 19 lets these hooks sit next to your old controlled forms with no drama, so convert one, ship it, and get a feel for the pending and error flow before you commit. For me it took exactly one real form to stop writing the old pattern by hand. My guess is it&rsquo;ll take you about the same.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>React 19&#8217;s useActionState and useFormStatus let me delete the loading flags, error state, and controlled inputs I hand-wrote in every form. Before and after code.<\/p>\n","protected":false},"author":2,"featured_media":507,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"React 19's useActionState and useFormStatus let me delete the loading flags, error state, and controlled inputs I hand-wrote in every form. Before and after code.","rank_math_focus_keyword":"react useactionstate","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[354],"tags":[229,38,41,359,136],"class_list":["post-508","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-react","tag-forms","tag-frontend","tag-react","tag-react-19-2","tag-useactionstate"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/508","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=508"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/508\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/507"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=508"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=508"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=508"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}