<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shubhra Pokhariya</title>
    <description>The latest articles on DEV Community by Shubhra Pokhariya (@shubhradev).</description>
    <link>https://dev.to/shubhradev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3462235%2Fa195c0cb-1004-4a1a-93f4-ecb8593c6884.jpg</url>
      <title>DEV Community: Shubhra Pokhariya</title>
      <link>https://dev.to/shubhradev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shubhradev"/>
    <language>en</language>
    <item>
      <title>React 19 useFormStatus Returning False? I Built a SubmitButton That Fixes It</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 29 Sep 2026 08:13:20 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19-useformstatus-returning-false-i-built-a-submitbutton-that-fixes-it-4o0j</link>
      <guid>https://dev.to/shubhradev/react-19-useformstatus-returning-false-i-built-a-submitbutton-that-fixes-it-4o0j</guid>
      <description>&lt;p&gt;A few weeks back I wrote about the useFormStatus bug that got me the hardest, the one where pending sits at false forever because the hook was called in the wrong component. If you haven't read that one, the short version is this: the hook only reports on a parent form, never one rendered by the same component calling it, and nothing warns you when you get it wrong. No error, no console message, the button just quietly never enters a pending state.&lt;/p&gt;

&lt;p&gt;That post explained the bug. This one is about what I actually built once I got tired of re-explaining the same fix to myself on every new project, and about a second version of that exact same silent failure I didn't notice until I'd already shipped the first fix.&lt;/p&gt;

&lt;p&gt;One scope note before the code: this fixes the placement mistake. If &lt;code&gt;pending&lt;/code&gt; is false because the form submits through a plain &lt;code&gt;onSubmit&lt;/code&gt; handler that only calls &lt;code&gt;preventDefault()&lt;/code&gt;, or through a URL string &lt;code&gt;action&lt;/code&gt;, moving the hook won't change anything, because there is no Action for it to track.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first post didn't cover
&lt;/h2&gt;

&lt;p&gt;Moving the hook fixes the placement bug, but I ran into two more problems as soon as I used it in real forms.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;disabled={pending}&lt;/code&gt; on the button itself. It works, and it's also the wrong choice if you care about keyboard users. A disabled element drops focus the instant it becomes disabled. Someone tabs to your submit button, hits Enter, and focus jumps away mid submission, sometimes landing nowhere useful at all. The fix is &lt;code&gt;aria-disabled&lt;/code&gt; instead of &lt;code&gt;disabled&lt;/code&gt;, with the click blocked manually so repeat submissions still get ignored.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SubmitButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;intent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;aria-disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;aria-busy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;data-pending&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleClick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Focus stays exactly where it was. Style the pending state off &lt;code&gt;aria-disabled&lt;/code&gt; or &lt;code&gt;data-pending&lt;/code&gt;, not &lt;code&gt;:disabled&lt;/code&gt;, since the native attribute is never actually set here.&lt;/p&gt;

&lt;p&gt;The second problem shows up once a form has two submit buttons. &lt;code&gt;pending&lt;/code&gt; belongs to the whole form, not to whichever button was clicked, so a Save Draft and a Publish button next to each other both light up for one click. The browser already knows which button submitted. Its name and value are in the FormData, so you read them back out and compare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useFormStatusForIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submittedIntent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;intent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isThisIntentPending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;submittedIntent&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;submittedIntent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isThisIntentPending&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;savePost&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;textarea&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"draft"&lt;/span&gt; &lt;span class="na"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Saving draft..."&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Save Draft&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"publish"&lt;/span&gt; &lt;span class="na"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Publishing..."&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Publish&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SubmitButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The button shown earlier is simplified to keep the focus on &lt;code&gt;aria-disabled&lt;/code&gt;. In the full file, &lt;code&gt;SubmitButton&lt;/code&gt; calls this hook, so only the button someone actually pressed shows pending. One gotcha before you wire this up: pressing Enter in a text field submits through whichever button comes first in the markup. That's the HTML spec's definition of the default button, not something React decides. Put your safest action first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking the rest of the form while it submits
&lt;/h2&gt;

&lt;p&gt;Next you'll probably want the rest of the form locked while the request is in flight, so nobody edits a field mid submit. A native &lt;code&gt;&amp;lt;fieldset&amp;gt;&lt;/code&gt; does this in one line instead of one &lt;code&gt;disabled&lt;/code&gt; prop per input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PendingFieldset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unstyled&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt;
      &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;unstyled&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;unstyled&lt;/code&gt; flag is for CSP. The inline reset removes the fieldset's default border, but a CSP that blocks inline styles ignores it on server-rendered HTML and the border comes back. If that's you, pass &lt;code&gt;unstyled&lt;/code&gt; and style it with a class.&lt;/p&gt;

&lt;p&gt;One trade-off: a focused input loses focus when the fieldset disables it, the same thing that happens to a disabled button. Use it where blocking edits matters more than keeping focus.&lt;/p&gt;

&lt;p&gt;For a screen reader announcement, &lt;code&gt;aria-busy&lt;/code&gt; alone isn't read out loud. You need an actual live region for that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FormStatusText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idleText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;aria-live&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt; &lt;span class="na"&gt;aria-atomic&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pendingText&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idleText&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The bug I didn't catch until the second pass
&lt;/h2&gt;

&lt;p&gt;Here's the part that made me go back and touch the file again after I thought it was already done.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SubmitButton&lt;/code&gt; had a dev-only check from the start. Render it outside a form and you get a console error in development explaining why it won't work.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PendingFieldset&lt;/code&gt; and &lt;code&gt;FormStatusText&lt;/code&gt; had the identical requirement, "must be rendered inside the form", written right there in their own comments, but neither one actually had the check. Misplace either one and it fails as silently as the original bug: the fieldset never disables, the status message never appears, and nothing warns you.&lt;/p&gt;

&lt;p&gt;It's an easy mistake to make, since these two feel less important than the submit button and tend to get dropped in wherever is convenient.&lt;/p&gt;

&lt;p&gt;Both now run the same placement check the button already had, with the same escape hatch for anyone using a portal on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PendingFieldset&lt;/span&gt; &lt;span class="na"&gt;unsafeSkipFormCheck&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* rendered through a portal: outside the form in the DOM, inside it in the React tree */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;PendingFieldset&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check has a limit. It only looks at the DOM, but &lt;code&gt;useFormStatus&lt;/code&gt; follows the React tree. A component rendered through a portal can track the form correctly and still trigger the warning, because in the DOM it isn't inside the form. That's what &lt;code&gt;unsafeSkipFormCheck&lt;/code&gt; is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this landed
&lt;/h2&gt;

&lt;p&gt;The full file has the button, the fieldset, the live region, the intent hook, and the placement check on all three components. One file, no dependencies beyond &lt;code&gt;react&lt;/code&gt; and &lt;code&gt;react-dom&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Known limits: &lt;code&gt;intent&lt;/code&gt; doesn't work with a per-button &lt;code&gt;formAction&lt;/code&gt;, &lt;code&gt;form.requestSubmit()&lt;/code&gt; skips the click guard, and two synchronous clicks in the same task both get through before React re-renders. I've only tested in Chromium and Firefox so far. Safari and screen readers are still untested.&lt;/p&gt;

&lt;p&gt;If you want the whole thing: &lt;a href="https://shubhra.dev/snippets/use-form-status-fix" rel="noopener noreferrer"&gt;useFormStatus SubmitButton&lt;/a&gt;. Free, single file.&lt;/p&gt;

&lt;p&gt;If you're catching this one first and want the full explanation of why the original bug happens, that's the &lt;a href="https://dev.to/shubhradev/react-19s-useformstatus-fixed-my-prop-drilling-then-it-sat-there-returning-false-30dl"&gt;earlier post&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>a11y</category>
    </item>
    <item>
      <title>React 19's useTransition Looked Simple. Then I Found a Second Bug Hiding Inside the First One</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:32:13 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19s-usetransition-looked-simple-then-i-found-a-second-bug-hiding-inside-the-first-one-35dk</link>
      <guid>https://dev.to/shubhradev/react-19s-usetransition-looked-simple-then-i-found-a-second-bug-hiding-inside-the-first-one-35dk</guid>
      <description>&lt;p&gt;Every hook in this series so far has been a passenger. &lt;code&gt;useActionState&lt;/code&gt; rides inside a Transition and reports back the result. &lt;code&gt;useOptimistic&lt;/code&gt; rides inside one and shows you a preview before the real answer lands. &lt;code&gt;useFormStatus&lt;/code&gt; doesn't even ride, it just reads a status from whoever else is driving. &lt;code&gt;useTransition&lt;/code&gt; is the only one of the four that sits in the driver's seat by itself, no form required, no other hook required.&lt;/p&gt;

&lt;p&gt;That's also why it's the easiest one to get partially right. You wrap a click handler in &lt;code&gt;startTransition&lt;/code&gt;, you get an &lt;code&gt;isPending&lt;/code&gt; flag, everything looks fine in your browser tab, and you ship it. Then someone double-clicks fast, or your function throws, or you try to hook it up to a text input, and the parts nobody mentioned start showing up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is a standalone guide.&lt;/strong&gt; If you're new to this series, everything below works on its own. If you've read &lt;a href="https://dev.to/shubhradev/react-19-actions-i-explained-3-hooks-without-ever-explaining-what-an-action-is-m79"&gt;Part 4&lt;/a&gt;, you already saw &lt;code&gt;useTransition&lt;/code&gt; make a brief appearance there through a wishlist button.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I tested every example below against React 19.2.8. I also re-ran the race condition, error boundary, and overlapping Transition checks on React 19.3.0, which shipped on September 9.&lt;/p&gt;

&lt;h2&gt;
  
  
  What useTransition Actually Gives You
&lt;/h2&gt;

&lt;p&gt;Call it at the top of a component and you get back exactly two things, always in this order.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductFilters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isPending&lt;/code&gt; is a boolean. It becomes &lt;code&gt;true&lt;/code&gt; at the first call to &lt;code&gt;startTransition&lt;/code&gt;, and stays &lt;code&gt;true&lt;/code&gt; until every Action in that Transition, including anything you awaited, has completed and the resulting state is shown. &lt;code&gt;startTransition&lt;/code&gt; is a function that takes one argument, a function of your own, and runs it right away. Nothing about &lt;code&gt;startTransition&lt;/code&gt; is delayed or scheduled for later. What changes is how React treats any &lt;code&gt;set&lt;/code&gt; call that happens while your function is running, those updates get marked as low priority and interruptible instead of urgent.&lt;/p&gt;

&lt;p&gt;That's the entire contract. No result value, no built-in error state, no queue. Compare that to &lt;code&gt;useActionState&lt;/code&gt; from Part 1, which hands you back whatever your function returns as state. &lt;code&gt;useTransition&lt;/code&gt; doesn't do that. If your function returns something, that return value is simply gone unless you store it yourself. This is the tradeoff for using &lt;code&gt;useTransition&lt;/code&gt; directly: less structure, but nothing standing between you and the raw mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Search Filter That Doesn't Freeze the List
&lt;/h2&gt;

&lt;p&gt;Forms and buttons aren't the only place a Transition earns its keep. Filtering a long list as someone types is a case &lt;code&gt;useActionState&lt;/code&gt; was never built for, there's no side effect to run, no single result to track, just a rendered list that needs to stay responsive while it re-renders on every keystroke.&lt;/p&gt;

&lt;p&gt;One precision point before the code: &lt;code&gt;startTransition&lt;/code&gt; does not make a computation run in the background. The function you pass to it runs immediately and synchronously, right when you call it. What gets marked as low priority and interruptible is the state update, and the render that follows from it, not whatever JavaScript happens to run inside the callback before that update fires. That distinction is the whole point of this example, so the two state variables below are split around it deliberately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductSearch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;filterQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFilterQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setFilterQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filterQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleChange&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Search products"&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;query&lt;/code&gt; drives the input and updates synchronously, so typing never lags. &lt;code&gt;filterQuery&lt;/code&gt; is the one wrapped in &lt;code&gt;startTransition&lt;/code&gt;, it "lags behind" &lt;code&gt;query&lt;/code&gt; on purpose. &lt;code&gt;filtered&lt;/code&gt; isn't stored in state at all, it's a plain expression computed fresh on every render from &lt;code&gt;filterQuery&lt;/code&gt;. The key point: the &lt;code&gt;products.filter()&lt;/code&gt; call itself still runs as an ordinary, uninterrupted piece of JavaScript, exactly like it would outside a Transition. What changed is &lt;em&gt;when&lt;/em&gt; that render happens and whether a more urgent update, another keystroke, is allowed to cut in front of it. React isn't chunking your array iteration, it's deciding whether the render containing that iteration gets to proceed right now or gets pushed behind something more urgent.&lt;/p&gt;

&lt;p&gt;This is also where the input-in-a-Transition limitation becomes concrete instead of abstract. You cannot wrap &lt;code&gt;query&lt;/code&gt; itself in &lt;code&gt;startTransition&lt;/code&gt;, only &lt;code&gt;filterQuery&lt;/code&gt; can go there. A controlled input's value has to update synchronously with every keystroke to feel correct, and &lt;a href="https://react.dev/reference/react/useTransition#updating-an-input-in-a-transition-doesnt-work" rel="noopener noreferrer"&gt;React's own troubleshooting docs&lt;/a&gt; name this exact two-state split as one of two fixes, the other being &lt;code&gt;useDeferredValue&lt;/code&gt; on a single state variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to reach for &lt;code&gt;useDeferredValue&lt;/code&gt; instead.&lt;/strong&gt; The rule of thumb is about who owns the &lt;code&gt;set&lt;/code&gt; function. Here, the component owns &lt;code&gt;setFilterQuery&lt;/code&gt; directly, so &lt;code&gt;useTransition&lt;/code&gt; is the right tool. If &lt;code&gt;ProductSearch&lt;/code&gt; instead received its query as a prop from a parent, or read it from some other hook you don't control the setter for, there'd be no &lt;code&gt;set&lt;/code&gt; call of your own to wrap in &lt;code&gt;startTransition&lt;/code&gt;. That's exactly the case &lt;code&gt;useDeferredValue&lt;/code&gt; exists for, you hand it the value, &lt;code&gt;const deferredQuery = useDeferredValue(query)&lt;/code&gt;, and it produces its own lagging copy without needing access to whoever's setting the original. Same lagging behavior, different entry point depending on whether you're driving the update or just receiving the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The await Rule, and Why It Actually Happens
&lt;/h2&gt;

&lt;p&gt;Part 4 showed the pattern for handling state updates after an &lt;code&gt;await&lt;/code&gt; inside a Transition, wrap them in a second &lt;code&gt;startTransition&lt;/code&gt; call. What it didn't get into is why React needs that second call at all, and that's worth understanding once instead of memorizing as a rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSave&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// this update is NOT part of the Transition&lt;/span&gt;
    &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React marks a &lt;code&gt;set&lt;/code&gt; call as part of a Transition by checking a flag while your function is running synchronously. The moment your function hits &lt;code&gt;await&lt;/code&gt;, execution yields back to the JavaScript engine, and that flag is gone by the time the code after &lt;code&gt;await&lt;/code&gt; resumes. React has no way to know that resumed code is a continuation of the same Transition rather than something unrelated. This is a JavaScript limitation, not a bug React chose not to fix, the language doesn't yet give React a way to track "this async continuation belongs to that earlier synchronous scope." A TC39 proposal called AsyncContext would close this gap, but it isn't part of the language yet.&lt;/p&gt;

&lt;p&gt;The fix is the same nested call every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSave&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveDraft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ✅ back inside a synchronous startTransition scope&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After every &lt;code&gt;await&lt;/code&gt;, state updates that need to remain part of the Transition must be wrapped in another &lt;code&gt;startTransition&lt;/code&gt;, &lt;a href="https://react.dev/reference/react/useTransition#react-doesnt-treat-my-state-update-after-await-as-a-transition" rel="noopener noreferrer"&gt;as the useTransition docs describe&lt;/a&gt;. Two sequential awaits in one Action means two separate re-entries into &lt;code&gt;startTransition&lt;/code&gt; for the updates that follow each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pending UI With No Form in Sight
&lt;/h2&gt;

&lt;p&gt;Every pending indicator earlier in this series read &lt;code&gt;isPending&lt;/code&gt; from &lt;code&gt;useActionState&lt;/code&gt; or &lt;code&gt;pending&lt;/code&gt; from &lt;code&gt;useFormStatus&lt;/code&gt;. &lt;code&gt;useFormStatus&lt;/code&gt; reads from a parent form, and &lt;code&gt;useActionState&lt;/code&gt; is built around the Actions you dispatch through it. &lt;code&gt;useTransition&lt;/code&gt;'s &lt;code&gt;isPending&lt;/code&gt; is the one you get when you start the Action yourself, with no form involved. It's just a boolean tied to whatever you wrapped, which means it works for things that were never going to be a form to begin with: a favorite toggle, a sort order change, a modal that loads data before it opens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SortButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sortKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onSort&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
      &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onSort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sortKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"spinner"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The button that triggered the change is the one that shows its own pending state, without a global loading flag and without prop drilling a status down from somewhere else. That's the same locality &lt;code&gt;useFormStatus&lt;/code&gt; gave you in Part 3, just reached through a different door, one that doesn't require a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; to walk through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors in a Bare Transition
&lt;/h2&gt;

&lt;p&gt;This is the gap Part 4 didn't touch, and it's a real one. &lt;code&gt;useActionState&lt;/code&gt; gives you a place to catch expected errors as returned state. A bare &lt;code&gt;useTransition&lt;/code&gt; call gives you nothing like that. If the function you pass to &lt;code&gt;startTransition&lt;/code&gt; throws, there's no result state to inspect, the throw propagates up to the nearest error boundary instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ErrorBoundary&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-error-boundary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AddToCartButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addToCart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not add item to cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleClick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Add to Cart
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CartButtonWithBoundary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Something went wrong adding this item.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AddToCartButton&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth being clear about what's actually happening here: &lt;code&gt;useTransition&lt;/code&gt; doesn't give you a custom error state or a new API surface for errors, that part is true. But it's not accurate to call this identical to any ordinary render-time error either. React specifically added &lt;a href="https://react.dev/reference/react/useTransition#displaying-an-error-to-users-with-error-boundary" rel="noopener noreferrer"&gt;Error Boundary support for Actions&lt;/a&gt;, so a throw inside &lt;code&gt;startTransition&lt;/code&gt; gets caught the way a throw in a plain event handler normally wouldn't, event handler errors have never been something a boundary catches on their own.&lt;/p&gt;

&lt;p&gt;Two things trip people up here. First, the error boundary has to wrap the component calling &lt;code&gt;useTransition&lt;/code&gt;, not just the button markup, an error boundary placed below the hook call won't catch anything. Second, this is an all-or-nothing tool: a thrown error takes down whatever the boundary wraps until the user or your code resets it. If you want a recoverable, inline error message instead of the whole section replaced by a fallback, don't throw, return an error value from the function and store it in state yourself. &lt;code&gt;useActionState&lt;/code&gt; gives you that pattern for free, since its return value becomes state automatically. Plain &lt;code&gt;useTransition&lt;/code&gt; doesn't, you'd be rebuilding a small piece of what &lt;code&gt;useActionState&lt;/code&gt; already does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Race Condition Nobody's First Draft Handles
&lt;/h2&gt;

&lt;p&gt;Here's the scenario Part 4 flagged and left open. A user clicks something that triggers an async Transition, changes their mind, and clicks again before the first request resolves. Both requests are now in flight. The response that arrives last is the one the component keeps, even if it belongs to the earlier click.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;QuantityStepper&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialQty&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialQty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateCartQuantity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// if a slower, earlier request resolves after this one,&lt;/span&gt;
        &lt;span class="c1"&gt;// its setQty runs last and the stale value wins&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;-&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;+&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://react.dev/reference/react/useTransition#my-state-updates-in-transitions-are-out-of-order" rel="noopener noreferrer"&gt;React's own reference material&lt;/a&gt; for this exact scenario says it plainly: Actions inside a Transition don't guarantee execution order once you &lt;code&gt;await&lt;/code&gt; something, because the async boundary loses that ordering context the same way it loses the Transition flag. &lt;code&gt;useActionState&lt;/code&gt; sidesteps this for its own dispatcher by queuing calls and running them one at a time, but a raw &lt;code&gt;useTransition&lt;/code&gt; call gets no such protection.&lt;/p&gt;

&lt;p&gt;There's actually a second problem hiding in this code, and it's easy to miss because it looks like the same bug. &lt;code&gt;updateQty(qty - 1)&lt;/code&gt; reads &lt;code&gt;qty&lt;/code&gt; from the render that produced this particular click handler, and &lt;code&gt;setQty&lt;/code&gt; only fires once the network request resolves. Click &lt;code&gt;+&lt;/code&gt; twice within that window, before the first request's response has actually come back and updated &lt;code&gt;qty&lt;/code&gt;, and both clicks compute their next value from the same starting number, sending the same result twice instead of incrementing twice. This isn't a same-tick React batching quirk, clicks are handled as separate events and React doesn't merge them. It's simpler and more common than that: the state genuinely hasn't changed yet, because the only thing that changes it is still sitting on the wire. Fixing the request race doesn't fix this one, they need separate solutions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;QuantityStepper&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialQty&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialQty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestedQty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialQty&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latestRequestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// authoritative next value, independent of what's currently rendered&lt;/span&gt;
    &lt;span class="nx"&gt;requestedQty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requestedQty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;latestRequestId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateCartQuantity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// only apply this result if nothing newer has been fired since&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;latestRequestId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nf"&gt;setQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;-&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;qty&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;updateQty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;+&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two separate fixes doing two separate jobs. &lt;code&gt;requestedQty&lt;/code&gt; is a &lt;code&gt;ref&lt;/code&gt; that tracks the next value to send independently of the rendered &lt;code&gt;qty&lt;/code&gt;, so it keeps accumulating correctly across rapid clicks no matter how far behind the actual render is. &lt;code&gt;latestRequestId&lt;/code&gt; is the token that solves the original problem, a stale response checks whether it's still the most recent request before it's allowed to apply. A &lt;code&gt;ref&lt;/code&gt; is the right tool for both because neither one needs to trigger a render on its own, they just need to survive across renders and stay current. This isn't the only way to solve the ordering half, an &lt;code&gt;AbortController&lt;/code&gt; tied to each request works too if your fetch layer supports cancellation, though aborting only stops the client from waiting, and the server may already be processing that mutation. The token check needs nothing from your API beyond what you probably already have.&lt;/p&gt;

&lt;p&gt;Two limits worth knowing. &lt;code&gt;requestedQty&lt;/code&gt; advances at click time, so it assumes the server accepts the requested value as sent. If the server clamps or normalizes that value, the ref and &lt;code&gt;qty&lt;/code&gt; can drift apart. The request token only decides which response can update the UI. It doesn't cancel the earlier request or guarantee that the server applies the updates in the order they were sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Reach for useTransition and When Not To
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reach for it&lt;/strong&gt; when you need pending tracking for something that isn't a form submission and doesn't need a tracked result value: filtering, sorting, tab switches, toggles, navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't reach for it&lt;/strong&gt; for a controlled input's own value, that has to stay synchronous or typing will visibly lag. Don't reach for it when you need the function's return value as state, that's what &lt;code&gt;useActionState&lt;/code&gt; captures automatically and &lt;code&gt;useTransition&lt;/code&gt; doesn't. Don't reach for it assuming request ordering is handled, it isn't, not without the token pattern above or an equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep in mind&lt;/strong&gt; that the &lt;code&gt;useTransition&lt;/code&gt; reference still lists a limitation: if multiple Transitions are ongoing, React currently batches them together. React 19.3 changed something nearby. According to the &lt;a href="https://react.dev/blog/2026/09/09/react-19-3" rel="noopener noreferrer"&gt;React 19.3 release post&lt;/a&gt;, a Transition with a slow render no longer holds up unrelated ones. So don't assume several pending indicators settle independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves the Series
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need to...&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Own a form's result and pending state in one place&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useActionState&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show finished UI before a server response confirms it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useOptimistic&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read a form's pending state from a component that isn't managing it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useFormStatus&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run pending-tracked work outside a form, with full control yourself&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useTransition&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Part 4 called these four answers to four different questions sharing one mechanism. Having now used the fourth one without any of the other three in the room, that framing holds up. &lt;code&gt;useTransition&lt;/code&gt; isn't the beginner-friendly entry point of the group, it's the one with the fewest guardrails, which is exactly why it's also the one that can do things the form-shaped hooks were never built for.&lt;/p&gt;

&lt;p&gt;I went through the out-of-order version of this bug in a plain search box in &lt;a href="https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m"&gt;I Throttled My App to Slow 3G. Here's What My Tests Never Caught&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So which one have you already shipped without knowing it, a response landing out of order or two fast clicks reading the same stale value? Drop it below.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>react</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Code I Couldn't Leave Alone</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:20:21 +0000</pubDate>
      <link>https://dev.to/shubhradev/the-code-i-couldnt-leave-alone-33hf</link>
      <guid>https://dev.to/shubhradev/the-code-i-couldnt-leave-alone-33hf</guid>
      <description>&lt;p&gt;The bug report was simple enough. On the reports dashboard, if you changed the date range while a filter was already active, the page number didn't reset. So a user could end up on page 4 of a report that now only had one page of results, staring at an empty table with no idea why.&lt;/p&gt;

&lt;p&gt;I found it fast. The pagination state and the filter state were living in two different places, &lt;code&gt;useState&lt;/code&gt; in the table component and &lt;code&gt;useSearchParams&lt;/code&gt; for the filters, and nothing was watching one to reset the other. I added a &lt;code&gt;useEffect&lt;/code&gt; that reset the page to 1 whenever the search params changed. It was the smallest change that unbroke the dashboard, and I wasn't about to restructure the component tree for a twenty minute fix. Tested it locally, clicked through a few filter combinations, watched the empty table problem disappear. Twenty minutes, maybe less.&lt;/p&gt;

&lt;p&gt;I didn't open the pull request yet. I told myself I wanted to read through the diff once more before pushing.&lt;/p&gt;

&lt;p&gt;That's when I actually looked at &lt;code&gt;ReportFilters.tsx&lt;/code&gt; for the first time in a while, and I didn't love what I saw. The filter state existed in two places at once. Some of it lived in the URL through &lt;code&gt;useSearchParams&lt;/code&gt;, which is what actually drove the API call, and some of it lived in local component state that only existed to control what the dropdowns displayed. Most of the time they matched. Occasionally, if you clicked fast enough, they didn't, and you'd see a dropdown showing one filter while the table quietly fetched with another. Nobody had filed a bug for that. I don't think anyone had ever managed to click fast enough to notice.&lt;/p&gt;

&lt;p&gt;But I'd noticed it now, and once you've seen a bug like that, it's hard to unsee it. It reminded me of a &lt;a href="https://dev.to/shubhradev/the-optimistic-ui-race-condition-that-only-showed-up-on-the-fifth-click-5a55"&gt;race condition I once tracked down in a checkbox that looked right on screen while the requests behind it quietly disagreed&lt;/a&gt;, the same kind of mismatch between what the UI shows and what actually happened. Different bug, same shape.&lt;/p&gt;

&lt;p&gt;So I pulled the local state out entirely and made the URL params the single source of truth, reading everything through &lt;code&gt;useSearchParams&lt;/code&gt; and writing through &lt;code&gt;router.replace&lt;/code&gt;. That's a real fix. Two sources of truth for the same data is exactly the kind of thing that eventually causes a much worse bug than the one I'd started with. I felt good about that change, and I still do.&lt;/p&gt;

&lt;p&gt;While I was in there, I noticed the filter logic itself, the actual function that turned the search params into a query object, was duplicated. It existed once in the component to build the fetch request, and again inside &lt;code&gt;app/api/reports/route.ts&lt;/code&gt; to validate incoming params on the server. Same logic, written twice, slightly out of sync, because someone had updated one copy when a new filter type was added and forgotten the other. I pulled it into a shared &lt;code&gt;lib/filters.ts&lt;/code&gt; and imported it in both places. That's not a nice to have. That's the kind of drift that can quietly turn into a production bug.&lt;/p&gt;

&lt;p&gt;At this point I'd fixed the original bug, removed a genuine duplicate source of truth, and closed a real gap between client and server validation. If I'd stopped there, this would just be a normal Tuesday. I didn't stop there.&lt;/p&gt;

&lt;p&gt;Once the filter logic was centralized, I looked at the route handler again and thought, well, since I'm already reading through this, the report data itself doesn't actually need to be client fetched at all. It's not interactive in any way that requires it. It could be a server component, fetched directly with the filters coming from &lt;code&gt;searchParams&lt;/code&gt; in the page props, no client-side fetch, no loading spinner, no waterfall. In my head it looked roughly like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ReportsPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getReports&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;range&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReportsTable&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;reports&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a bigger change than a bug fix. It touches the page structure, it means rethinking how the table gets its data, it means the loading state disappears entirely because the server just renders with the data already there.&lt;/p&gt;

&lt;p&gt;I started doing it anyway.&lt;/p&gt;

&lt;p&gt;I got about halfway through converting the table to a server component before I stopped, not because it was going badly, but because I glanced at the git diff panel in my editor and the file count had gone from one changed file to seven. Seven files, for a bug that was, structurally, a missing line inside a &lt;code&gt;useEffect&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I sat there for a second looking at that number, and the question that actually stopped me wasn't "is this too much work." It was smaller and quieter than that. I asked myself whether the server component conversion was something the product needed right now, or whether it was something I wanted to do because I could see exactly how to do it and it bothered me to leave it undone once I'd seen it.&lt;/p&gt;

&lt;p&gt;I didn't have a clean answer immediately, which is honestly what made me pay attention. The filter state fix and the duplicated logic fix, I could justify those in one sentence each, to myself or to a reviewer. The server component rewrite took a full paragraph to justify, and most of that paragraph was about how satisfying the final version would be, not about what was currently broken for anyone using the dashboard.&lt;/p&gt;

&lt;p&gt;That's usually the tell, I think. Not the size of the change. Plenty of small changes are indulgent and plenty of large ones are necessary. It's how long the justification takes, and who the justification is actually for.&lt;/p&gt;

&lt;p&gt;So I split it. I finished the bug fix and the shared filter logic, wrote a focused pull request that a reviewer could read in five minutes and understand completely, merged it the same day. The server component rewrite went into its own branch with its own description, explaining what it would remove, what it would simplify, and why it was worth doing on its own terms instead of riding in quietly behind a one-line bug fix. It's a good change. I still believe that. It just needed to stand on its own, reviewed and evaluated as what it actually was, instead of hiding inside something smaller.&lt;/p&gt;

&lt;p&gt;What stays with me isn't that I almost shipped too much. It's how reasonable every single step felt while I was inside it. Nobody would have blinked at any individual commit. The dropdown desync fix, obviously worth doing. The shared filter util, obviously worth doing. Even the server component conversion, on its own merits, was obviously worth doing. Stacked together in one sitting, driven by one bug report, they stopped being a response to a problem and became a response to my own discomfort with leaving the file the way I found it.&lt;/p&gt;

&lt;p&gt;I don't think that discomfort is something to train out of myself. It's most of why I'm good at this. Being able to see the second problem while you're fixing the first one is a real skill, and I'd rather have it than not. But seeing a change and shipping a change used to be the same motion for me, and somewhere in that two hour stretch I finally felt the gap between them instead of just hearing about it in theory.&lt;/p&gt;

&lt;p&gt;I closed the laptop that evening having shipped exactly one thing, a twenty minute bug fix with a real structural improvement folded honestly into it. The bigger rewrite is sitting in review on its own branch, where it belongs, where someone can actually look at it and ask whether it's worth the seven files, instead of me deciding that alone at ten at night because I couldn't stand leaving a route handler half server, half client, one more day.&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>I Throttled My App to Slow 3G. Here's What My Tests Never Caught</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 15 Sep 2026 05:41:21 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m</link>
      <guid>https://dev.to/shubhradev/i-throttled-my-app-to-slow-3g-heres-what-my-tests-never-caught-h7m</guid>
      <description>&lt;p&gt;I wasn't looking for a performance problem. I wanted to see what happened when the network got slow enough that assumptions I normally never notice had time to surface. So I opened DevTools, set the throttle to 3G (Chrome's network throttling reference dropped the separate "Slow 3G" preset a while back and just calls it "3G" now, sitting between Slow 4G and Offline), and started clicking around an app that had never once misbehaved on my normal wifi.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23s2oxd43g6zajn00y6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F23s2oxd43g6zajn00y6t.png" alt="Chrome DevTools Network panel with the throttling dropdown open, showing 3G selected between Slow 4G and Offline" width="784" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things broke. Both were correctness bugs, not "feels slow" complaints, and both were sitting in code that had passed every test I'd written for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: a race condition, search results arriving in the wrong order
&lt;/h2&gt;

&lt;p&gt;The app has a search box. Type a query, it fetches results, it shows them. Nothing fancy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a race condition, and on fast wifi you'll rarely notice it, because request and response times are close enough together that ordering rarely flips. That's not a guarantee, it's just what low, consistent latency looks like most of the time. Slow the connection down and that stops holding. If you type "apple", pause, then quickly change it to "banana", the "apple" request and the "banana" request are both in flight at once. Whichever one happens to take longer, maybe it hit a slower part of the network, maybe the server did more work for it, can land after the other one. When it does, its response overwrites the screen with results for a query you're not even looking at anymore.&lt;/p&gt;

&lt;p&gt;If this sounds familiar, I went through a close cousin of this bug in &lt;a href="https://dev.to/shubhradev/the-optimistic-ui-race-condition-that-only-showed-up-on-the-fifth-click-5a55"&gt;The Optimistic UI Race Condition That Only Showed Up on the Fifth Click&lt;/a&gt;, stale results from out-of-order requests aren't unique to search boxes, they show up anywhere a component fires more than one async request over its lifetime.&lt;/p&gt;

&lt;p&gt;The 3G throttle on its own makes this easier to hit, since it throttles every request in the session regardless of which path it takes. But it doesn't guarantee a flip on any given run, so to make it reproducible for this post I forced it deterministically: I gave the two queries different artificial delays on the server, apple takes 3 seconds, banana takes 200 milliseconds. That's a server-side delay, not the network throttle. For the logs and timings below, I ran the test with throttling off so the numbers are clean and come from the server delay alone. With throttling on, the same race still happens, but the network adds less predictable latency on top, which makes the timing harder to read in a screenshot.&lt;/p&gt;

&lt;p&gt;Click apple, then banana right after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent request for "apple"
sent request for "banana"
got response for "banana" after 210ms
got response for "apple" after 3001ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiq37p4mdj9lh4vnkpmjd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiq37p4mdj9lh4vnkpmjd.png" alt="Browser showing the search demo with the fix off, apple's slow response overwrote banana's result even though banana was searched last" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyqghk3keebjtdhf6p75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftyqghk3keebjtdhf6p75.png" alt="Terminal log confirming the server received apple first and banana second, but responded to banana first" width="800" height="229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screen ends up showing apple's results, because that's whichever response arrived last, not whichever request the user cared about last.&lt;/p&gt;

&lt;p&gt;The fix is to stop trusting arrival order and start tracking which request is actually the latest one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;latestQueryId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thisId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;latestQueryId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thisId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;latestQueryId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// a newer request has already been sent&lt;/span&gt;
  &lt;span class="nf"&gt;setResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same two requests, same order of arrival, one result on screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent request for "apple" (id 1)
sent request for "banana" (id 2)
got response for "banana" (id 2) after 210ms
got response for "apple" (id 1) after 3001ms
   IGNORED: a newer request has been sent since this one fired
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy42w8a7zck1jn2sqghix.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy42w8a7zck1jn2sqghix.png" alt="Same demo with the fix on, the stale apple response gets ignored and banana's result stays on screen" width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Another option is &lt;code&gt;AbortController&lt;/code&gt;, which can abort the previous fetch when a new query starts. That's a different tradeoff from request IDs, not the same mechanism wearing a different name. Aborting stops the client-side fetch from continuing to wait on that response. It doesn't necessarily stop the server from finishing the work it already started. Tracking request IDs instead lets the old request finish, then explicitly ignores its response if it's gone stale. I went with request IDs here because the search endpoint is cheap and I didn't need to cancel anything server-side, just make sure a late response can't win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the autosave that saves twice, or drops your last edit
&lt;/h2&gt;

&lt;p&gt;This one comes from a client-side timeout that assumed a round trip would always be fast.&lt;/p&gt;

&lt;p&gt;The autosave gave up and retried if it hadn't heard back within 1.2 seconds, a reasonable number if saves normally take a few hundred milliseconds. On a throttled connection a save can genuinely take two or three seconds without anything actually being wrong. The client can't tell "slow" apart from "failed." It just knows the timer ran out, assumes the worst, and fires a second save while the first one is still in flight and about to succeed on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;save #1 sent
save #1 client-side timeout, treating as failed and retrying
save #2 sent
save #1 confirmed (arrived after the retry already fired)
save #2 confirmed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frd34670v9w8wwj8kd80g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frd34670v9w8wwj8kd80g.png" alt="Naive autosave demo showing repeated client-side timeouts and retries piling up before settling" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two saves for one edit. If the save endpoint isn't strictly idempotent, that's two writes that can produce different results depending on what changed in between, not just a harmless duplicate.&lt;/p&gt;

&lt;p&gt;My first fix was a simple in-flight guard: skip firing a new save while one is already out. That stops the duplicate, but it introduces a different bug. If you edit again while a save is in flight, that edit gets silently dropped instead of ever being sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user types "hello", save #1 starts
user types "hello world" while save #1 is still in flight
   skipped (save in flight), edit "hello world" is dropped
save #1 confirmed: "hello"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsnjhphmsx3ey2tenvpq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsnjhphmsx3ey2tenvpq.png" alt="In-flight guard demo where the textarea shows " width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For this autosave behavior, the version you want doesn't skip the newer edit, it queues it and sends it right after the current save finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;saveInFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saveInFlight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;saveInFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;postSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;saveInFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pendingContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pendingContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try/finally&lt;/code&gt; matters here, not just style. Without it, if &lt;code&gt;postSave&lt;/code&gt; ever rejects, &lt;code&gt;saveInFlight&lt;/code&gt; stays &lt;code&gt;true&lt;/code&gt; forever and every future edit gets silently queued and never sent. That's a worse bug than the one you started with, because it fails quietly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;save #1 sent: "hello"
skipped (save in flight), queued latest: "hello world"
save #1 confirmed: "hello"
save #2 sent: "hello world"
save #2 confirmed: "hello world"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiswfoomuxelccf158fip.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiswfoomuxelccf158fip.png" alt="Coalescing fix demo, the queued edit fires right after the first save confirms, nothing dropped, nothing duplicated" width="800" height="584"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No duplicate requests, and the latest edit still gets saved. (If you edit again while that queued save is itself in flight, it just replaces &lt;code&gt;pendingContent&lt;/code&gt; again, that's the coalescing working as intended, only ever the most recent edit waits in line.) This is the simple version of the queueing strategy, worth saying plainly: a production autosave also needs an explicit policy for what happens when a save actually fails, not just when it's slow. This code handles slow, not failed.&lt;/p&gt;

&lt;p&gt;The actual lesson here isn't "guard against duplicate saves", it's that a client-side timeout only tells you a response hasn't arrived yet. It doesn't tell you what happened on the server, and if the operation has side effects, retrying it needs an explicit strategy for what happens to work that's already in flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  What both actually have in common
&lt;/h2&gt;

&lt;p&gt;I didn't go looking for two bugs about ordering and timing specifically. I went looking for whatever would show up if I stopped assuming my network was representative of anyone else's. Both of these surfaced because each one was built around an assumption that happened to hold on my machine and broke somewhere else. Responses usually arrive in the order you send them, until they don't. A couple seconds is usually enough time for a save to complete, until it isn't.&lt;/p&gt;

&lt;p&gt;Neither of my test suites caught either of these, because neither ran on a slow connection. They ran on the same fast, forgiving network I was developing on, which is exactly the network that hides both of these bugs. Same pattern I ran into with a completely different upgrade in &lt;a href="https://dev.to/shubhradev/nextjs-16-broke-my-app-in-4-places-and-none-of-them-threw-an-error-51mn"&gt;Next.js 16 Broke My App in 4 Places and None of Them Threw an Error&lt;/a&gt;, none of these bugs throw, none of them fail loud, they just quietly do the wrong thing until you go looking under the exact condition that triggers them.&lt;/p&gt;

&lt;p&gt;If your code assumes responses arrive in request order, or treats a client timeout as proof of failure, you likely have a version of one of these sitting in code you already trust, whether or not you've seen it misbehave yet.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Tested generateStaticParams in Next.js Cache Components. My Layout Broke Twice</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:22:33 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-tested-generatestaticparams-in-nextjs-cache-components-my-layout-broke-twice-502m</link>
      <guid>https://dev.to/shubhradev/i-tested-generatestaticparams-in-nextjs-cache-components-my-layout-broke-twice-502m</guid>
      <description>&lt;p&gt;The last post here got picked apart in the comments in the best possible way. &lt;a class="mentioned-user" href="https://dev.to/nazar-boyko"&gt;@nazar-boyko&lt;/a&gt; asked the sharpest version of the question I'd skipped entirely: if the ids are known at build time, does the page go fully static, or does Cache Components still want a stream, cache, or block answer regardless. &lt;a class="mentioned-user" href="https://dev.to/mudassirworks"&gt;@mudassirworks&lt;/a&gt; flagged the nested-component wrinkle, two server components independently awaiting &lt;code&gt;params&lt;/code&gt;, and where the Suspense boundary actually has to sit. &lt;a class="mentioned-user" href="https://dev.to/suraj09"&gt;@suraj09&lt;/a&gt;, &lt;a class="mentioned-user" href="https://dev.to/glenallen"&gt;@glenallen&lt;/a&gt;, and &lt;a class="mentioned-user" href="https://dev.to/merbayerp"&gt;@merbayerp&lt;/a&gt; circled the same architecture question from different angles, what happens once a layout and the page underneath it both depend on the same runtime data. &lt;a class="mentioned-user" href="https://dev.to/alexshev"&gt;@alexshev&lt;/a&gt; pushed on something none of us had done, turning a one-off fix into something that survives after the post is forgotten. None of it had a real answer from me at the time, so I built a fresh project and went and got one.&lt;/p&gt;

&lt;p&gt;So I built a fresh test project, four routes this time instead of one, and ran every one of these to an actual answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does generateStaticParams make a route static?
&lt;/h2&gt;

&lt;p&gt;Short version: no. I gave a route three known ids up front and left the data access unguarded, same as the original test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductStaticPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build fails, and it names id 1 specifically, one of the exact three I told it about ahead of time. Knowing the id at build time and being allowed to skip the stream, cache, or block decision are two different things.&lt;/p&gt;

&lt;p&gt;That part's a clean answer, but not the interesting one. The interesting part is what happens once you actually pick a fix, because the two fixes I tried don't produce the same result.&lt;/p&gt;

&lt;p&gt;Wrap the data access in Suspense, and the build passes, but all three known ids come out marked as Partial Prerender. The data stays deferred behind the static shell, the same as it does for an id the route has never seen. Swap that for caching the data function instead, one word changed, &lt;code&gt;"use cache"&lt;/code&gt; instead of a Suspense wrapper, and those same three ids flip to fully static, baked into the build output.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5racutvuki8tdb0ekh07.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5racutvuki8tdb0ekh07.webp" alt="Build output showing all three known ids from generateStaticParams marked fully static after switching the fix from Suspense to use cache" width="800" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the real answer to Nazar's question. &lt;code&gt;generateStaticParams&lt;/code&gt; decides which paths get attempted. Whether they end up static depends on the work the route performs for those params and how that work gets handled, not on the ids being known ahead of time by itself.&lt;/p&gt;

&lt;p&gt;One more dead end worth flagging before you go looking for it yourself: I tried pairing &lt;code&gt;generateStaticParams&lt;/code&gt; with the old &lt;code&gt;dynamicParams = false&lt;/code&gt; option, expecting a 404 for anything outside the known set. &lt;code&gt;dynamicParams&lt;/code&gt; still exists as a route segment config in Next.js generally, but it's disabled specifically once &lt;code&gt;cacheComponents&lt;/code&gt; is on, so the build rejects it instead of applying it. In this Cache Components setup, unlisted ids are handled at request time instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that took three attempts
&lt;/h2&gt;

&lt;p&gt;This is the one I'd read even if you skip everything else, because it's the part that answers Suraj, Glen, and Mustafa's question, and it's the part that actually surprised me.&lt;/p&gt;

&lt;p&gt;Setup: a layout and a page sharing a route segment, both reading the same id, both calling the same two data functions. Both unguarded. Build fails, expected.&lt;/p&gt;

&lt;p&gt;My first move was the obvious one: fix the page, leave the layout alone, since I'd already handled the page's own component.&lt;/p&gt;

&lt;p&gt;Still failed. Fixing the page did nothing for an unguarded &lt;code&gt;params&lt;/code&gt; access in the layout above it. A Suspense boundary in the page can't reach back and cover work that already happened in the parent before the page even rendered.&lt;/p&gt;

&lt;p&gt;So I fixed the layout too, wrapped the component reading the data in its own Suspense, the same move that had worked everywhere else so far.&lt;/p&gt;

&lt;p&gt;Still failed. Same error. This is where I stopped guessing and ran &lt;code&gt;next build --debug-prerender&lt;/code&gt; to get an actual trace instead of a route name.&lt;/p&gt;

&lt;p&gt;It pointed at &lt;code&gt;const { id } = await params;&lt;/code&gt;, sitting in the layout, above the Suspense boundary I'd just added. Not the data fetch inside the wrapped component. The line that produced the id in the first place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikmp5oj0s09u3if0vi42.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fikmp5oj0s09u3if0vi42.png" alt="Debug prerender trace pointing to the exact await params line in layout.tsx, above the Suspense boundary" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd wrapped the component that used the id. I hadn't wrapped the line that produced it.&lt;/p&gt;

&lt;p&gt;The real fix passes the whole &lt;code&gt;params&lt;/code&gt; promise down and resolves it inside the wrapped child, instead of resolving it in the parent and handing over a plain value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WorkspaceHeader&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workspace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getWorkspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WorkspaceLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WorkspaceHeader&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That builds clean. Same principle applied to the page underneath it.&lt;/p&gt;

&lt;p&gt;One negative result worth keeping, since it's the mistake I'd expect someone to reach for first on a real app: I also tried leaving both layers completely unguarded and just caching the two shared data functions instead, assuming that would cover both callers at once. It didn't touch the failure at all. The unguarded operation was never the data fetch, it was &lt;code&gt;await params&lt;/code&gt; itself, sitting outside any boundary in both places. Caching a function a broken access calls doesn't fix the access that calls it.&lt;/p&gt;

&lt;p&gt;Mudassir's nested-sibling case turned out to follow the same underlying rule, just one layer shallower: two independent components both reading &lt;code&gt;params&lt;/code&gt;, and wrapping only one of them left the other exposed. The fix isn't a specific boundary shape, it's making sure nothing reading runtime data sits outside every boundary, whether that's one shared wrapper or several separate ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually check on a real app
&lt;/h2&gt;

&lt;p&gt;Audit layouts before pages. A layout's unguarded access fails on its own terms, independent of anything you've already fixed below it, and it's the thing most likely to get missed because layouts don't feel like "the route" the way a page does.&lt;/p&gt;

&lt;p&gt;When a fix doesn't work and the error still points at something you've technically wrapped, check where the &lt;code&gt;await&lt;/code&gt; itself happens, not just where the result gets used. Resolving a promise above a boundary and passing the plain value into a wrapped child still counts as an unguarded access.&lt;/p&gt;

&lt;p&gt;Alex, on the regression test question, here's what I landed on: a small script that rebuilds, then hits each fixed route including the unlisted-id case, since a plain build won't catch that on its own. Worth being honest that an HTTP check only proves the route responds, not which rendering mode it's using. That classification only ever comes from the build output itself.&lt;/p&gt;

&lt;p&gt;I put the full four-route walkthrough, all the build screenshots, the exact debug trace, and the regression script in the &lt;a href="https://shubhra.dev/tutorials/nextjs-generatestaticparams-cache-components" rel="noopener noreferrer"&gt;full Next.js generateStaticParams and nested-layout walkthrough&lt;/a&gt; if you want the whole thing end to end rather than the condensed version here.&lt;/p&gt;

&lt;p&gt;Has anyone hit this layout-versus-page split in a real production route tree yet? I'm curious whether the same &lt;code&gt;params&lt;/code&gt; boundary issue shows up once several layouts start sharing data.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>React 19 Actions: I Explained 3 Hooks Without Ever Explaining What an Action Is</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:30:35 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19-actions-i-explained-3-hooks-without-ever-explaining-what-an-action-is-m79</link>
      <guid>https://dev.to/shubhradev/react-19-actions-i-explained-3-hooks-without-ever-explaining-what-an-action-is-m79</guid>
      <description>&lt;p&gt;Three parts into this series, and if you'd asked me to define the word sitting underneath every single hook I'd taught you, I'm not sure I could have done it cleanly. That's not false modesty. Go back and read Parts 1 through 3. I used the word "Action" constantly. I never once stopped to say what one actually is.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/shubhradev/react-19s-useactionstate-showed-me-why-disabling-my-submit-button-was-never-enough-53jd"&gt;Part 1&lt;/a&gt; opened with three state variables I'd hand-rolled for years, a result, a pending flag, an error, and showed how &lt;code&gt;useActionState&lt;/code&gt; collapses all three into one call. &lt;a href="https://dev.to/shubhradev/react-19s-useoptimistic-fixed-my-instant-ui-then-combining-it-with-useactionstate-broke-my-reset-41km"&gt;Part 2&lt;/a&gt; took a comment box and made it feel instant before the server had even replied. &lt;a href="https://dev.to/shubhradev/react-19s-useformstatus-fixed-my-prop-drilling-then-it-sat-there-returning-false-30dl"&gt;Part 3&lt;/a&gt; let a submit button three components away read a form's pending state with zero props passed down. Three different problems, three different hooks, and every single one of them was quietly leaning on the same mechanism without me ever naming it out loud.&lt;/p&gt;

&lt;p&gt;That's the gap this post closes. Not a new bug this time. The concept the last three bugs were all symptoms of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The word I kept using and never defined
&lt;/h2&gt;

&lt;p&gt;Here's the actual definition, straight from React's own docs, not a paraphrase I'm softening for effect. A function called inside &lt;code&gt;startTransition&lt;/code&gt; is an Action. That's it. That's the whole qualifying test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitFeedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveFeedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That function above could sit in your codebase forever and never once behave like an Action. What flips the switch has nothing to do with anything written inside it. It's entirely about the doorway it walks through. Four doorways do this: a direct &lt;code&gt;startTransition&lt;/code&gt; call, &lt;code&gt;useTransition&lt;/code&gt;'s version of the same call, a form's &lt;code&gt;action&lt;/code&gt; prop taking a function instead of a string, or &lt;code&gt;useActionState&lt;/code&gt;. Walk a function through any of them and it stops being a plain async function you're tracking by hand. What React takes over automatically is the Transition itself, the pending tracking underneath it. What it does not universally hand you is the function's return value. &lt;code&gt;useTransition&lt;/code&gt; gives you &lt;code&gt;isPending&lt;/code&gt; and nothing else, a raw &lt;code&gt;startTransition&lt;/code&gt; call doesn't return anything at all. &lt;code&gt;useActionState&lt;/code&gt; is the one doorway built specifically to catch that return value and hold onto it as state, which is exactly why &lt;code&gt;isPending&lt;/code&gt; and a real result showed up together back in Part 1 and nowhere else in this series. More on the ordering side of this once &lt;code&gt;useTransition&lt;/code&gt; shows up properly below.&lt;/p&gt;

&lt;p&gt;Once that clicked for me, Parts 1 through 3 stopped looking like three separate hooks and started looking like three different windows onto the same room. &lt;code&gt;useFormStatus&lt;/code&gt; reads the pending status of the nearest parent form, and now you know why, that pending status only exists in the first place because something wrapped the submission in a Transition. &lt;code&gt;useOptimistic&lt;/code&gt; is the odd one out. It isn't a door into becoming an Action at all. It's a setter you call from inside one that's already running. Call it outside a Transition and it just won't behave the way Part 2 showed you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why any of this needed inventing
&lt;/h2&gt;

&lt;p&gt;Part 1's opening line still holds up: every form needed the same three pieces of state, wired by hand, every time. That pattern wasn't unique to forms either. A delete button, a follow toggle, a checkout step, all needed the identical scaffolding rebuilt from scratch. React 19 added the ability to use async functions inside transitions in the first place, which is the foundation everything else in this series stands on. What you build on that foundation, whether you get a tracked result back or just a pending flag, depends on which of the four doorways you actually walk through. &lt;code&gt;useActionState&lt;/code&gt; is the one that goes furthest, catching the return value as state. The others give you less by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;&amp;lt;form action={fn}&amp;gt;&lt;/code&gt;, and the three things nobody tells you change
&lt;/h2&gt;

&lt;p&gt;Before Actions existed, a form's only real path to running code was &lt;code&gt;onSubmit&lt;/code&gt;, and that path came with requirements you had to remember every time. Call &lt;code&gt;preventDefault&lt;/code&gt;, manually construct &lt;code&gt;FormData&lt;/code&gt; from the event target, manage loading and error state by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OldForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// run the request, manage loading and error state yourself&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;onSubmit&lt;/code&gt; for a function passed straight to &lt;code&gt;action&lt;/code&gt; and the mental model shifts more than the syntax suggests. Nothing calls &lt;code&gt;preventDefault&lt;/code&gt;, not because you forgot it, but because React already knows this is an Action before the form ever fires, so the browser's default full-page reload was never going to happen in the first place. The function also stops receiving an event. It receives &lt;code&gt;FormData&lt;/code&gt; directly, already built from every named field on the page, which is one less step you used to write by hand every time. And the request method itself stops being something you configure at all. A function passed to &lt;code&gt;action&lt;/code&gt; always submits as POST, whatever you write in a &lt;code&gt;method&lt;/code&gt; attribute sitting right next to it. That last one is the exact fact Part 3 ran into from the &lt;code&gt;useFormStatus&lt;/code&gt; side, back when &lt;code&gt;method&lt;/code&gt; kept coming back &lt;code&gt;'post'&lt;/code&gt; no matter what I expected walking in. It was never reading an attribute. It was reading the Action underneath it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;onSubmit&lt;/code&gt; still earns its keep for one job specifically. Anything that has to stop a submission before it starts, checking two password fields match, belongs there. By the time code inside the Action itself runs, the submission has already begun, so nothing in there can be the thing that prevents it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sync Actions exist. They're just not interesting
&lt;/h2&gt;

&lt;p&gt;An Action doesn't have to be async. A plain synchronous function handed to the same four doors still counts, React still manages its lifecycle, but the pending window is usually too short to build UI around.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resetSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instant &lt;code&gt;await&lt;/code&gt; shows up inside that function, everything from Parts 1 through 3 becomes worth having. React 18 required &lt;code&gt;startTransition&lt;/code&gt;'s callback to be synchronous. React 19 dropped that restriction, and that single change is what let &lt;code&gt;useActionState&lt;/code&gt;, &lt;code&gt;&amp;lt;form action={fn}&amp;gt;&lt;/code&gt;, and &lt;code&gt;useTransition&lt;/code&gt; all accept async functions directly instead of you managing the async boundary by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client Actions and Server Functions are not the same word twice
&lt;/h2&gt;

&lt;p&gt;Everything in this series so far has been a client Action, code that runs in the browser whether or not it talks to a server underneath. That version works identically in any React 19 setup, no framework required.&lt;/p&gt;

&lt;p&gt;Server Functions are a related but genuinely separate idea, and they only exist in frameworks with Server Component support, Next.js being the obvious one. React's own docs are specific about the relationship here, and it's worth getting right instead of using the two terms interchangeably like I nearly did drafting this: a Server Function only becomes a Server Action once it's passed to an &lt;code&gt;action&lt;/code&gt; prop or called from inside an Action. Not every Server Function is a Server Action. Every Server Action used this way is a Server Function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitFeedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;formData&lt;/code&gt; argument is the shape a Server Function receives when it's handed directly to a form's &lt;code&gt;action&lt;/code&gt; prop. It's worth flagging because it's not interchangeable with &lt;code&gt;useActionState&lt;/code&gt; as-is, which always calls its function with &lt;code&gt;(previousState, formData)&lt;/code&gt;, the two-argument shape Part 1 introduced. Reusing server logic through &lt;code&gt;useActionState&lt;/code&gt; means writing for that shape from the start, not dropping in a single-argument version and hoping React adapts it for you. It won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fourth door: useTransition
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;useTransition&lt;/code&gt; hasn't come up by name once in this series, and the mechanism it exposes has been running underneath every single example anyway. Calling it gives you exactly two things back, an &lt;code&gt;isPending&lt;/code&gt; flag and a &lt;code&gt;startTransition&lt;/code&gt; function.&lt;/p&gt;

&lt;p&gt;Every checkout example so far ran inside a &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;, which wraps its Action in a Transition automatically. &lt;code&gt;useTransition&lt;/code&gt; is for when there's no form to lean on. Picture a wishlist heart icon sitting on a product card, no form anywhere near it, just a click handler that needs to hit the server and report whether it's pending.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WishlistButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isSaved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toggleWishlist&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransition&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSaved&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isSaved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setSaved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toggleWishlist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setSaved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleClick&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;aria-pressed&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;♥ Saved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;♡ Save&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that second, nested &lt;code&gt;startTransition&lt;/code&gt; wrapping &lt;code&gt;setSaved&lt;/code&gt; after the &lt;code&gt;await&lt;/code&gt;. If that looks familiar, it should, it's the exact same shape as &lt;code&gt;onConfirmed&lt;/code&gt; getting wrapped in its own &lt;code&gt;startTransition&lt;/code&gt; back in Part 2's comment box. That's not two separate patterns you happened to see twice. React only automatically tracks synchronous work as part of a Transition. Anything after an &lt;code&gt;await&lt;/code&gt; needs its own &lt;code&gt;startTransition&lt;/code&gt; call to still count, whether you're inside a comment form or a heart icon with no form in sight.&lt;/p&gt;

&lt;p&gt;Notice too that the wishlist button above reaches for plain &lt;code&gt;useState&lt;/code&gt;, not &lt;code&gt;useOptimistic&lt;/code&gt;. That's not an oversight. This example exists to isolate what &lt;code&gt;useTransition&lt;/code&gt; gives you entirely on its own, before any of the other three hooks get involved. In a real app you'd almost certainly reach for &lt;code&gt;useOptimistic&lt;/code&gt; here instead, the exact instant-feedback job it was built for back in Part 2. Showing it with bare &lt;code&gt;useState&lt;/code&gt; first is the only way to actually see what &lt;code&gt;useTransition&lt;/code&gt; alone is doing.&lt;/p&gt;

&lt;p&gt;Here's the part that's easy to miss and expensive to find out the hard way. Click that heart, unclick it, click it again fast, and a raw &lt;code&gt;useTransition&lt;/code&gt; call gives you no guarantee about which result lands last. React says so directly: Actions inside a Transition don't guarantee execution order on their own. What's easy to assume, and what I nearly wrote into this section before checking, is that &lt;code&gt;&amp;lt;form action={fn}&amp;gt;&lt;/code&gt; is just as exposed to that same problem as a bare &lt;code&gt;useTransition&lt;/code&gt; call. It isn't. React's own docs group &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; actions together with &lt;code&gt;useActionState&lt;/code&gt; as the two built-in ways that already handle ordering for you. It's raw &lt;code&gt;useTransition&lt;/code&gt;, used on its own with nothing else wrapping it, that gets no such guarantee, the wishlist button above included. If you've been assuming that guarantee comes from Actions in general, it doesn't. It's specifically what &lt;code&gt;useActionState&lt;/code&gt; and &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; actions add on top of the raw mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the four actually sit next to each other
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need to...&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Own a form's result and pending state in one place&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useActionState&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show the finished UI before the server has confirmed it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useOptimistic&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read a form's pending state from a component that isn't managing it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useFormStatus&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run an Action outside a form and track its pending state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useTransition&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They were never four competing answers to "which hook should I learn." They're four answers to four different questions that happen to share one mechanism underneath. The checkout form from Part 3 already proved it by combining two of them across four separate components, &lt;code&gt;useActionState&lt;/code&gt; owning the submission, &lt;code&gt;useFormStatus&lt;/code&gt; letting &lt;code&gt;PlaceOrderButton&lt;/code&gt; read pending state with nothing passed down. Here's that same order form with a third hook layered on, a promo code that discounts the total the moment the form is submitted with it, before the server has confirmed anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useOptimistic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useFormStatus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PlaceOrderButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Placing order...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OrderForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;cartTotal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;displayTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useOptimistic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;cartTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;promoCode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SAVE10&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Total: $&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;displayTotal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"promoCode"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Promo code"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PaymentFields&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PlaceOrderButton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useActionState&lt;/code&gt; still owns the real submission the same way it did in Part 3. &lt;code&gt;useOptimistic&lt;/code&gt; shows the discounted total the moment the form submits with &lt;code&gt;SAVE10&lt;/code&gt; in the field, no waiting on &lt;code&gt;submitOrder&lt;/code&gt; to confirm anything first, and it falls back on its own to whatever &lt;code&gt;cartTotal&lt;/code&gt; currently is once the Transition settles. That fallback detail matters more than it looks. This trimmed-down version never actually updates &lt;code&gt;cartTotal&lt;/code&gt; after &lt;code&gt;submitOrder&lt;/code&gt; resolves, so the discount would vanish the instant the Action finishes, same trap Part 2 walked through with &lt;code&gt;onConfirmed&lt;/code&gt;. A real version needs the parent to fold the confirmed, discounted total back into &lt;code&gt;cartTotal&lt;/code&gt; once the order actually goes through, or the optimistic value has nothing real to settle into. &lt;code&gt;useFormStatus&lt;/code&gt; still lets &lt;code&gt;PlaceOrderButton&lt;/code&gt; disable itself with nothing passed down from &lt;code&gt;OrderForm&lt;/code&gt;. Three hooks, one form, none of them stepping on the other's job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake I nearly baked into this whole series
&lt;/h2&gt;

&lt;p&gt;Here's the trap I think this series set without meaning to. Teach one hook per post, three posts in a row, and a careful reader could walk away thinking the real task was picking a favorite, useActionState or useOptimistic or useFormStatus, as if only one of them belongs in a given form. That was never true. Composition was always the point. A real form rarely reaches for exactly one of these. It reaches for whichever two or three actually solve the piece of the problem sitting in front of it, and none of them are competing for the same job.&lt;/p&gt;

&lt;p&gt;I built this series backwards on purpose, honestly, three concrete bugs before the concept underneath them, because I think a concept sticks harder once you've already felt the shape of the problem it solves. But it only works if the concept actually shows up eventually. This is that part.&lt;/p&gt;

&lt;p&gt;If you want the deeper cut, sync versus async Actions in more detail, the full Server Functions section, and a proper FAQ, I wrote the complete version on my site: &lt;a href="https://shubhra.dev/tutorials/react-19-actions" rel="noopener noreferrer"&gt;React 19 Actions Explained&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you'd rather test what actually stuck first, I also built a 15-question quiz covering the same four APIs: &lt;a href="https://shubhra.dev/quiz/react-19-actions-quiz" rel="noopener noreferrer"&gt;React 19 Actions Quiz&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So here's the real question, the one I'd genuinely like an answer to. Which of these four have you already been using without knowing you were touching an Action? A &lt;code&gt;&amp;lt;form action={fn}&amp;gt;&lt;/code&gt; you wired up because it looked simpler than &lt;code&gt;onSubmit&lt;/code&gt;, a &lt;code&gt;startTransition&lt;/code&gt; call you copied from somewhere without reading what it does, a button that felt laggy until you wrapped it in &lt;code&gt;useTransition&lt;/code&gt; and never asked why that fixed it. Drop it below. I'm curious how many of us have been using this mechanism for months without a name for it.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Turned On Cache Components in Next.js 16.3. It Refused to Build My Simplest Page.</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:58:25 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-turned-on-cache-components-in-nextjs-163-it-refused-to-build-my-simplest-page-3ak0</link>
      <guid>https://dev.to/shubhradev/i-turned-on-cache-components-in-nextjs-163-it-refused-to-build-my-simplest-page-3ak0</guid>
      <description>&lt;p&gt;I didn't want to write another "what's new in Next.js 16.3" post. Enough of those exist. I wanted to know what actually happens when you flip the flags on a real project, so I scaffolded a fresh app, turned on &lt;code&gt;cacheComponents&lt;/code&gt; and &lt;code&gt;partialPrefetching&lt;/code&gt;, and tried to build the simplest possible dynamic page.&lt;/p&gt;

&lt;p&gt;It didn't build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Nothing fancy. Fresh &lt;code&gt;create-next-app&lt;/code&gt;, Next.js 16.3.1, two flags in &lt;code&gt;next.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cacheComponents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;partialPrefetching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a product page. No database, no external API, not even a real &lt;code&gt;fetch&lt;/code&gt;, just a fake delay and the route's own &lt;code&gt;params&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Product &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-2xl font-bold"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;$&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's about as plain as a dynamic route gets. I expected &lt;code&gt;next build&lt;/code&gt; to shrug and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Route "/products/[id]": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `&amp;lt;Suspense&amp;gt;` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `&amp;lt;Suspense fallback={...}&amp;gt;` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx6xztuvbzyhjcf8c2o93.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx6xztuvbzyhjcf8c2o93.png" alt="Next.js 16.3 build error for params route showing the uncached-or-runtime-data error with stream/cache/block options" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Full build failure. Exit code 1. For a page whose only "dynamic" behavior is reading a param that's obviously going to be there.&lt;/p&gt;

&lt;p&gt;My first reaction was that this felt aggressive. My second reaction, after rereading it, was that this is the whole point of the release and I'd missed it by only skimming the changelog. Cache Components in 16.3 doesn't treat an unguarded dynamic access as a warning. It treats it as a decision you have to make, explicitly, before it'll ship your build. Stream it, cache it, or mark it blocking on purpose. No silent fourth option where it just works and you find out it was never instant three months from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what it actually buys you
&lt;/h2&gt;

&lt;p&gt;I took the option the error suggested first: wrap the part that awaits &lt;code&gt;params&lt;/code&gt; in &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Product &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductDetails&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-2xl font-bold"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;$&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading product...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductDetails&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ran the build again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Route (app)
┌ ○ /
├ ○ /_not-found
└   /products/[id]
  └ ◐ /products/[id]

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisenk3r21smamudli2ew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fisenk3r21smamudli2ew.png" alt="Next.js 16.3 successful build output showing Partial Prerender symbol ◐ for /products/[id]" width="688" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;◐&lt;/code&gt; next to my product route is the actual feature. Next.js pulled out everything around the &lt;code&gt;Suspense&lt;/code&gt; boundary, the shell, and prerendered it. The part that actually depends on which product you clicked streams in behind it. That's Cache Components' Partial Prerendering doing exactly what it's supposed to, a real route in a real build output, not a description from the release notes. It's also the same shell mechanism that Partial Prefetching, the client-side navigation feature 16.3 adds on top of this, depends on existing in the first place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdzunbzpskynmp7ib4zfv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdzunbzpskynmp7ib4zfv.png" alt="Browser rendering the product page at localhost:3000/products/123 showing Product 123 and $42" width="757" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What this told me that the changelog didn't
&lt;/h2&gt;

&lt;p&gt;Reading about Partial Prefetching, "a reusable shell per route, cached on the client," sounds like a nice-to-have you'd adopt when you get around to it. Hitting the build error told me something different: on a 16.3 app with Cache Components on, there's no version of "get around to it." Every uncached or runtime access Next.js finds during prerendering needs an answer, stream, cache, or block, the moment you turn the flag on. It's not gradual. It's a build gate.&lt;/p&gt;

&lt;p&gt;That's a bigger adoption cost than "add two lines to your config" makes it sound, and I think that's exactly why Next.js ships &lt;code&gt;next-cache-components-adoption&lt;/code&gt; as a first-party agent Skill now instead of leaving you to hunt down every unguarded &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;cookies()&lt;/code&gt; call by hand across a real app. On a five-file test project this took me two minutes. On a real production route tree, I wouldn't want to do it without that tool, or without a very good sense of where every Suspense boundary in the app already is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd actually start
&lt;/h2&gt;

&lt;p&gt;If you're evaluating this for a real app: don't turn the flag on for the whole project and see what catches fire. Pick one route, ideally your simplest dynamic one, and watch what the build tells you it needs. It will tell you exactly what to do next. That part, at least, the error message gets right.&lt;/p&gt;

&lt;p&gt;I wrote a longer breakdown of the full 16.3 release, what upgrades automatically with zero config changes, what Instant Navigations and Partial Prefetching actually change, and what shipped for AI agents, &lt;a href="https://shubhra.dev/blog/nextjs-16-3-two-upgrades" rel="noopener noreferrer"&gt;on my website&lt;/a&gt; if you want the whole picture before you decide whether to flip these flags on something real.&lt;/p&gt;

&lt;p&gt;Has anyone else run the migration on an actual production route tree yet? I'd like to know what the first pass looks like on a real app.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
    </item>
    <item>
      <title>React 19's useFormStatus Fixed My Prop Drilling. Then It Sat There Returning False</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:04:31 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19s-useformstatus-fixed-my-prop-drilling-then-it-sat-there-returning-false-30dl</link>
      <guid>https://dev.to/shubhradev/react-19s-useformstatus-fixed-my-prop-drilling-then-it-sat-there-returning-false-30dl</guid>
      <description>&lt;p&gt;Part 1 was about waiting well. Part 2 was about not waiting at all. This one is about a value I needed in a component that never created it, and about the ten minutes I lost putting the hook in the one place it can't work.&lt;/p&gt;

&lt;p&gt;If you've read &lt;a href="https://dev.to/shubhradev/react-19s-useactionstate-showed-me-why-disabling-my-submit-button-was-never-enough-53jd"&gt;Part 1 on useActionState&lt;/a&gt; and &lt;a href="https://dev.to/shubhradev/react-19s-useoptimistic-fixed-my-instant-ui-then-combining-it-with-useactionstate-broke-my-reset-41km"&gt;Part 2 on useOptimistic&lt;/a&gt;, both of those hooks answer a question from inside the component that called them. That was never a problem for a two-field newsletter form. It became one the day I split an order form into separate pieces, payment details in one file, the place-order button in another, and both of them needed to know the same thing: is this thing submitting right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that made prop drilling look reasonable
&lt;/h2&gt;

&lt;p&gt;Here's roughly what the form looked like before I touched &lt;code&gt;useFormStatus&lt;/code&gt; at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useActionState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PaymentFields&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cardNumber"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Card number"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cardExpiry"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"MM/YY"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PlaceOrderButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Placing order...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OrderForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PaymentFields&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PlaceOrderButton&lt;/span&gt; &lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about this is broken. It works. But &lt;code&gt;isPending&lt;/code&gt; lives in &lt;code&gt;OrderForm&lt;/code&gt;, and both children only exist to receive it and hand it to a &lt;code&gt;disabled&lt;/code&gt; attribute. Add a shipping section, an order-summary sidebar, a coupon field that should lock during submission, and every one of them needs the same prop threaded through, whether or not anything else about them changes. I'd read that &lt;code&gt;useFormStatus&lt;/code&gt; was built to skip exactly this, so I went looking for where to put it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I put it first, and why it did nothing
&lt;/h2&gt;

&lt;p&gt;My first instinct was to call the hook right where &lt;code&gt;isPending&lt;/code&gt; used to live, in &lt;code&gt;OrderForm&lt;/code&gt; itself, treating it like a drop-in replacement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useFormStatus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OrderForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// stays false forever&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;placeOrder&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PaymentFields&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PlaceOrderButton&lt;/span&gt; &lt;span class="na"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I clicked "Place order." Nothing disabled. No console warning, no red error overlay, just a button sitting there fully clickable while the network tab showed a request in flight. My first guess was that &lt;code&gt;placeOrder&lt;/code&gt; wasn't actually async, or that I'd broken something in the action itself. I spent a few minutes staring at the action function before I even looked back at the hook.&lt;/p&gt;

&lt;p&gt;The actual answer is simpler and, once you see it, a little obvious. &lt;code&gt;useFormStatus&lt;/code&gt; doesn't read state that &lt;code&gt;OrderForm&lt;/code&gt; owns. It reads the status of the parent &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;, and at the moment that hook call runs, &lt;code&gt;OrderForm&lt;/code&gt; is still deciding what to render. The &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; tag doesn't exist as anyone's parent yet, it's the thing being built. There's no ancestor for the hook to find, so it falls back to its default, unpending shape, and it stays that way no matter how long the real submission takes.&lt;/p&gt;

&lt;p&gt;The rule underneath that: the component calling &lt;code&gt;useFormStatus&lt;/code&gt; has to be a descendant of the form it wants to read, never the component rendering the form itself. Fixing it meant pulling the hook out of &lt;code&gt;OrderForm&lt;/code&gt; and putting it somewhere further down the tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useFormStatus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PaymentFields&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cardNumber"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Card number"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cardExpiry"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"MM/YY"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;fieldset&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PlaceOrderButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Placing order...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OrderForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;placeOrder&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PaymentFields&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PlaceOrderButton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same markup, same behavior at a glance, and suddenly &lt;code&gt;pending&lt;/code&gt; flips correctly. Neither child receives anything from &lt;code&gt;OrderForm&lt;/code&gt; anymore. Both just ask their parent form what's going on and get told the truth.&lt;/p&gt;

&lt;p&gt;One detail that's easy to miss on the way there: this hook comes from &lt;code&gt;react-dom&lt;/code&gt;, not &lt;code&gt;react&lt;/code&gt;. &lt;code&gt;useActionState&lt;/code&gt; and &lt;code&gt;useOptimistic&lt;/code&gt; both live in &lt;code&gt;react&lt;/code&gt;, so if you're moving through this series in order, typing &lt;code&gt;import { useFormStatus } from "react"&lt;/code&gt; is a genuinely easy slip, and it fails in a way that doesn't tell you what went wrong either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I didn't expect: it's watching more than a boolean
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;pending&lt;/code&gt; worked, I assumed that was the whole hook. It isn't. Calling it gives you an object with three more properties riding along, and I only found a real use for them once I stopped treating &lt;code&gt;useFormStatus&lt;/code&gt; as a fancy boolean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data&lt;/code&gt; is the &lt;code&gt;FormData&lt;/code&gt; currently being submitted, available only while something is in flight. I used it to make the button say something more specific than a generic spinner label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PlaceOrderButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFormStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Placing order for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;...`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Place order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;data&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt; the rest of the time, so that &lt;code&gt;.get("email")&lt;/code&gt; call only runs safely because it's tucked inside the &lt;code&gt;pending&lt;/code&gt; branch of the ternary. Pull it out and call it unconditionally, and the component throws the first time it renders idle.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;method&lt;/code&gt; is where I got tripped up briefly, because I assumed it would mirror whatever &lt;code&gt;method&lt;/code&gt; attribute I'd have put on the form. It doesn't work that way once you're using a function-based action. Any form using the &lt;code&gt;action&lt;/code&gt; prop with a function, which is the entire reason this hook has something to track in the first place, submits as &lt;code&gt;'post'&lt;/code&gt; regardless of anything else you configure. You'd only see &lt;code&gt;'get'&lt;/code&gt; here on a form using a plain URL string as its action, or no &lt;code&gt;action&lt;/code&gt; prop at all, and the docs only commit to one thing about that case: &lt;code&gt;action&lt;/code&gt; itself comes back &lt;code&gt;null&lt;/code&gt;. They stay quiet on &lt;code&gt;pending&lt;/code&gt; and &lt;code&gt;data&lt;/code&gt;, and since only function-based actions actually run through React's Transition tracking, that's not a combination I'd build anything around.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;action&lt;/code&gt; is a reference back to whatever function you passed to the form's &lt;code&gt;action&lt;/code&gt; prop. It's &lt;code&gt;null&lt;/code&gt; for a form with no function-based action, and it's also &lt;code&gt;null&lt;/code&gt; if there's no parent form at all, which means a falsy value here doesn't automatically tell you which of those two is true. I hit this once trying to conditionally render something based on whether &lt;code&gt;action&lt;/code&gt; existed, and had to remind myself both cases collapse to the same falsy check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reused-component trick I didn't plan for, and the one I got burned by
&lt;/h2&gt;

&lt;p&gt;The nicest surprise came later, almost by accident. &lt;code&gt;PlaceOrderButton&lt;/code&gt; wasn't just used on the checkout page. It got reused, unmodified, on a "save draft order" form elsewhere in the app. I expected to need some kind of context or ID to tell it which form it belonged to. I didn't. &lt;code&gt;useFormStatus&lt;/code&gt; resolves the nearest ancestor form independently everywhere it's rendered, so the same component read the correct pending state on both forms without a single line of extra wiring. That's not an edge case worth working around, it's a real reason to build small, reusable status-aware components in the first place.&lt;/p&gt;

&lt;p&gt;The edge case that did surprise me went the other direction. Somewhere in an admin panel, a designer had wired a button outside its form using the HTML &lt;code&gt;form="orderForm"&lt;/code&gt; attribute instead of physically nesting it. Pure HTML, no React opinion needed, and it submits the form fine. &lt;code&gt;useFormStatus&lt;/code&gt; inside that button read as permanently not-pending, even while the form it was technically attached to was mid-submission. The hook walks the component tree, not the DOM-level &lt;code&gt;form&lt;/code&gt; attribute association, so as far as it's concerned, that button was never a child of anything. Worth knowing before it surprises you in a codebase that uses this attribute anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I stopped reaching for it
&lt;/h2&gt;

&lt;p&gt;Not every form needed this. On a two-field signup form with a single button in the same component, &lt;code&gt;useActionState&lt;/code&gt;'s own &lt;code&gt;isPending&lt;/code&gt;, read directly where the hook was called, did the job without adding a second hook and a second rule to remember. &lt;code&gt;useFormStatus&lt;/code&gt; earns its place once a form's parts genuinely live in separate components, not before.&lt;/p&gt;

&lt;p&gt;And it only ever tells you about a submission already underway. &lt;code&gt;useFormStatus&lt;/code&gt; isn't a validation API, it tells you what the form is doing once submission has started, not whether that submission should have been allowed to start in the first place. That decision still belongs upstream, in the action function or wherever else you're handling the submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where useActionState, useOptimistic, and useFormStatus actually sit
&lt;/h2&gt;

&lt;p&gt;Three parts into this series and the boundaries finally made sense to me as one picture instead of three separate hooks to memorize. &lt;code&gt;useActionState&lt;/code&gt; owns a result and reacts to it, from wherever it was called. &lt;code&gt;useOptimistic&lt;/code&gt; shows the finished UI before the server has actually agreed to it. &lt;code&gt;useFormStatus&lt;/code&gt; answers one question, is this form pending, from any component underneath it, with nothing passed down to make that possible.&lt;/p&gt;

&lt;p&gt;The distinction that finally made it stick for me: &lt;code&gt;useActionState&lt;/code&gt;'s &lt;code&gt;isPending&lt;/code&gt; follows an Action, specifically the one dispatched through that hook call. &lt;code&gt;useFormStatus&lt;/code&gt;'s &lt;code&gt;pending&lt;/code&gt; follows a form, whichever &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; happens to be its nearest parent, regardless of which component or which action put it there. That's why the same &lt;code&gt;PlaceOrderButton&lt;/code&gt; worked unmodified on two different forms with two different actions later on. It was never watching an action. It was watching whatever form it happened to be sitting inside.&lt;/p&gt;

&lt;p&gt;They stack without fighting each other. The order form I started with ended up using two of the three, &lt;code&gt;useActionState&lt;/code&gt; in the parent owning the result, &lt;code&gt;useFormStatus&lt;/code&gt; in every child section reading the same pending state independently.&lt;/p&gt;

&lt;p&gt;I wrote the full breakdown on my site, including the complete four-section checkout example combining both hooks, the exact difference between this and &lt;code&gt;useActionState&lt;/code&gt;'s own &lt;code&gt;isPending&lt;/code&gt;, and a side-by-side table for picking the right one: &lt;a href="https://shubhra.dev/tutorials/react-19-useformstatus" rel="noopener noreferrer"&gt;React 19 useFormStatus Explained&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want to test what you just learned, I also put together a short &lt;a href="https://shubhra.dev/quiz/react-19-useformstatus-quiz" rel="noopener noreferrer"&gt;React 19 useFormStatus Quiz&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've built a form where the submit button lives three components away from the one managing the submission, it's worth checking where you actually called the hook before you assume something's broken. I know that's exactly where I lost ten minutes I didn't need to.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Know the Recipe. I Miss the Winter Kitchen.</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Sat, 08 Aug 2026 17:16:03 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-know-the-recipe-i-miss-the-winter-kitchen-39ea</link>
      <guid>https://dev.to/shubhradev/i-know-the-recipe-i-miss-the-winter-kitchen-39ea</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/frontend-2026-07-29"&gt;Frontend Challenge - Comfort Food Edition, CSS Art&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Inspiration
&lt;/h2&gt;

&lt;p&gt;When I first read Comfort Food, my mind immediately went somewhere I haven't visited in years.&lt;/p&gt;

&lt;p&gt;Not to pizza. Not to desserts. Not even to my favorite recipes.&lt;/p&gt;

&lt;p&gt;It went back to my childhood kitchen.&lt;/p&gt;

&lt;p&gt;During winter, even though we had a gas stove, my mother would often light our clay chulha. The kitchen felt warmer, the fire crackled softly, and one by one the phulkas would puff over the flame.&lt;/p&gt;

&lt;p&gt;I still remember sitting there with my father and siblings, all of us eating from the same plate while waiting for the next phulka to arrive. It wasn't a special recipe. Just wheat flour, water, fire, and my mother's hands.&lt;/p&gt;

&lt;p&gt;I've eaten many wonderful meals since then, but somehow those simple phulkas are still the first thing that comes to mind when someone asks me about comfort food.&lt;/p&gt;

&lt;p&gt;I still remember how she would quietly place another phulka in front of me before I could even say I was full. If I protested, she'd smile and insist, "Just one more." Somehow, there was always one more.&lt;/p&gt;

&lt;p&gt;Today I still love cooking. I know exactly how to make soft phulkas myself. But every now and then I realize I was never missing the recipe.&lt;/p&gt;

&lt;p&gt;I was missing that winter kitchen. That feeling of waiting together. That warmth.&lt;/p&gt;

&lt;p&gt;Somewhere while writing this, I stopped thinking about the challenge. I wasn't writing a submission anymore. I was simply writing down a memory I never wanted to forget.&lt;/p&gt;

&lt;p&gt;Before anyone else reads this, I already know who I want to send it to first. My siblings. Because they'll know exactly which kitchen I'm talking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://codepen.io/shubhradev/pen/019fe1e2-3a56-70e5-8569-237ee20c426f" rel="noopener noreferrer"&gt;View the live demo on CodePen&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What you're looking at: a clay chulha, firewood at the opening, flames glowing between the sticks, and a phulka cooking on the tawa. Nothing else. I wanted to keep the scene simple and let the story carry everything that isn't visible in the CSS.&lt;/p&gt;

&lt;p&gt;The interaction: tap the phulka. It rises and settles, and one line stays on screen for a moment. I never missed the recipe. I missed the winter kitchen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Journey
&lt;/h2&gt;

&lt;p&gt;As a frontend developer, I wanted this project to be more than drawing food with CSS. I wanted to get the shape right, because the shape is the first thing anyone who grew up around a chulha will recognize, and the first thing that would look wrong to them if I got it wrong.&lt;/p&gt;

&lt;p&gt;My first pass was a rounded dome. It looked warm, but it didn't look like a chulha. It read more like generic firelight. The chulha I remember has a solid, tapered clay body with a flat cooking surface on top. I reshaped the base and used layered gradients and border-radius to give the clay body its rounded, hand-built form, then moved the fire into a proper opening at the front instead of letting the flames float above the structure. That single change did more for the piece than every gradient I'd added before it.&lt;/p&gt;

&lt;p&gt;The firewood took a few tries. Real sticks angle outward from the opening at different rows, not in a neat fan, so I gave each one its own rotation and length instead of repeating one shape three times. The flames sit low among the sticks now, several small uneven tongues instead of a few tall symmetrical ones, each flickering on its own timing so they never move in sync.&lt;/p&gt;

&lt;p&gt;The phulka's lighting was the detail I'm proudest of getting right. The fire sits below and in front of it, so the warm highlight has to come from underneath, not from a generic top-left light source the way most CSS food art defaults to. Once I flipped that, it stopped looking like a painted circle and started looking like something actually sitting near heat.&lt;/p&gt;

&lt;p&gt;The puff interaction runs on a single checkbox and the &lt;code&gt;:has()&lt;/code&gt; selector. No JavaScript anywhere in the file. Tap the phulka, it lifts with a spring easing, and the line fades in at the top of the scene. &lt;code&gt;prefers-reduced-motion&lt;/code&gt; turns off the fire and smoke animations and settles everything into a still, warm frame for anyone who needs that.&lt;/p&gt;

&lt;p&gt;I started with more in the frame, a rolling pin, a cloth, even the whole kitchen at one point. Every version I stripped back felt more honest than the one before it. I realized I didn't need to recreate the entire kitchen to tell the story. The kitchen, my mother, my siblings, the warmth, and those hot phulkas are all part of the memory, but the chulha and the phulka are what I could recreate in CSS without trying to turn the memory itself into an illustration. So I kept the scene simple: the chulha, the fire, and the phulka. Everything else lives in the story behind it.&lt;/p&gt;

&lt;p&gt;Comfort food isn't always the most elaborate meal. Sometimes it's the simplest one, because what we remember isn't the recipe. It's the people we shared it with.&lt;/p&gt;

&lt;p&gt;I hope everyone who sees this remembers a kitchen that instantly takes them back home, wherever home may be.&lt;/p&gt;

&lt;p&gt;Built with pure CSS. No JavaScript, no frameworks, no libraries.&lt;/p&gt;

</description>
      <category>frontendchallenge</category>
      <category>devchallenge</category>
      <category>css</category>
    </item>
    <item>
      <title>React 19's useOptimistic Fixed My Instant UI. Then Combining It With useActionState Broke My Reset Button</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 04 Aug 2026 16:39:10 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19s-useoptimistic-fixed-my-instant-ui-then-combining-it-with-useactionstate-broke-my-reset-41km</link>
      <guid>https://dev.to/shubhradev/react-19s-useoptimistic-fixed-my-instant-ui-then-combining-it-with-useactionstate-broke-my-reset-41km</guid>
      <description>&lt;p&gt;Part 1 was about waiting well. This one is about not waiting at all, and about a couple of mistakes I made along the way that are worth walking through in the open.&lt;/p&gt;

&lt;p&gt;If you read &lt;a href="https://dev.to/shubhradev/react-19s-useactionstate-showed-me-why-disabling-my-submit-button-was-never-enough-53jd"&gt;Part 1 on useActionState&lt;/a&gt;, you already know the shape of the problem it solves: three hand-rolled state variables, a try/catch/finally, and a pending flag you're hoping stays in sync with reality. useActionState fixes that for the "wait for the server, then show the result" case, and I closed that piece by saying useOptimistic was the next hook worth learning. This is that piece. But some interactions never wanted to wait in the first place. A comment posts. A like fires. A checklist item gets ticked off. The user expects to see the outcome the instant they act, not the instant the network agrees with them.&lt;/p&gt;

&lt;p&gt;That's the gap useOptimistic fills. And building a real example that combines it with useActionState is what exposed two mistakes I'd made without realizing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old trick, and why it quietly lied
&lt;/h2&gt;

&lt;p&gt;Before this hook existed, "instant" UI usually meant something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLike&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setLiked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// update now, hope the request agrees later&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/like&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setLiked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// manually undo it if the request fails&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, until it doesn't. Forget the catch block and a failed request leaves the UI lying to the user forever. Get a second click in before the first request resolves and you're racing two local booleans against two network calls with no ordering guarantee between them. I shipped a version of this on a follow button once and spent an evening figuring out why the count would occasionally drift by one after a flaky connection. The state wasn't wrong because of a typo. It was wrong because nothing was actually managing the relationship between what I was showing and what was confirmed.&lt;/p&gt;

&lt;p&gt;useOptimistic manages exactly that relationship, and it does it without you writing a single line of manual rollback logic, as long as you actually wire it up right. Keep reading, because I didn't, on the first pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does, briefly
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;optimisticState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOptimistic&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useOptimistic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reducer&lt;/span&gt;&lt;span class="p"&gt;?);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;value&lt;/code&gt; is your real, confirmed state, the thing you'd render if nothing were in flight. &lt;code&gt;optimisticState&lt;/code&gt; matches &lt;code&gt;value&lt;/code&gt; right up until you call the setter inside a Transition, at which point it temporarily reflects whatever you passed in. Once the Transition settles, &lt;code&gt;optimisticState&lt;/code&gt; collapses back to &lt;code&gt;value&lt;/code&gt;. Not a value you have to reset. Not a flag you have to flip back. It falls back on its own, because that was always the only thing it was ever equal to once nothing's pending.&lt;/p&gt;

&lt;p&gt;I go through the full API, the updater-function-versus-reducer decision, and the edge cases in the &lt;a href="https://shubhra.dev/tutorials/react-19-useoptimistic" rel="noopener noreferrer"&gt;complete useOptimistic tutorial&lt;/a&gt;, so I won't repeat that ground here. What I want to get into is what happens when useActionState and useOptimistic end up in the same form, specifically around resetting it, because that's where things stopped being simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining them: a comment box
&lt;/h2&gt;

&lt;p&gt;Here's the version I ended up with, after fixing what I got wrong the first time around.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useOptimistic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CommentBox&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onConfirmed&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;postComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;comment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Comment can't be empty.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onConfirmed&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postComment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;optimisticComments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addOptimisticComment&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useOptimistic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;comment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;addOptimisticComment&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleReset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;optimisticComments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"comment"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Post&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleReset&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Reset
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"alert"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CommentBox&lt;/code&gt; takes an &lt;code&gt;onConfirmed&lt;/code&gt; callback the same way the upvote example in the full tutorial does, and it matters for the same reason. useOptimistic never updates the real &lt;code&gt;comments&lt;/code&gt; prop for you. Something outside the hook has to. Once &lt;code&gt;saveComment&lt;/code&gt; resolves, &lt;code&gt;postComment&lt;/code&gt; calls &lt;code&gt;onConfirmed&lt;/code&gt; with the confirmed comment, and the parent is expected to fold that into its own state, the same pattern as this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CommentThread&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;postId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setComments&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialComments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CommentBox&lt;/span&gt;
      &lt;span class="na"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onConfirmed&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="nf"&gt;setComments&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip that wiring and you'll watch every comment you post disappear the moment the Transition settles, even though the server accepted it. That's not a hypothetical. It's the exact "shows you the future, doesn't make it real" mistake the full tutorial calls out, and it's worth restating here because it's easy to leave out of an example like this without noticing.&lt;/p&gt;

&lt;p&gt;Notice too that &lt;code&gt;onConfirmed&lt;/code&gt; is called inside its own &lt;code&gt;startTransition&lt;/code&gt;, nested after the &lt;code&gt;await&lt;/code&gt;. That's not decoration. If you update real state after an await inside a reducerAction, the docs are specific that it needs its own Transition wrapper, the same requirement the tutorial's edge cases section covers for the LikeButton example.&lt;/p&gt;

&lt;p&gt;One more small thing worth calling out, because I got it wrong on an earlier draft of this same example: the id for each optimistic comment is generated with &lt;code&gt;crypto.randomUUID()&lt;/code&gt; in &lt;code&gt;handleSubmit&lt;/code&gt;, before it ever reaches the updater passed to &lt;code&gt;addOptimisticComment&lt;/code&gt;. Not inside the updater itself. The reducer or updater you give &lt;code&gt;useOptimistic&lt;/code&gt; has to be pure, the docs say so directly, and something like &lt;code&gt;Date.now()&lt;/code&gt; called from inside it isn't. React can invoke that function more than once for the same update, and a fresh id each time it's called turns one comment into two different keys in your list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why remounting breaks more than the form
&lt;/h2&gt;

&lt;p&gt;useActionState doesn't give you a way to clear its own state automatically. One common fix is bumping a &lt;code&gt;key&lt;/code&gt; prop to force the whole component to remount, and on its own that's a reasonable option. It does reset the form. This is a different thing from &lt;code&gt;requestFormReset&lt;/code&gt;, which React 19 also ships. That one clears uncontrolled DOM field values, not the state useActionState is holding on to, so it solves a narrower problem than the one we're talking about here.&lt;/p&gt;

&lt;p&gt;The remount trick has a cost that doesn't show up until useOptimistic enters the picture. A remount doesn't just clear useActionState's result. It tears down and rebuilds the whole component tree underneath that key, and any useOptimistic state riding along in that same component gets torn down with it. If a Transition using that optimistic state hadn't settled yet, the optimistic item doesn't get replaced by the real one. It just vanishes.&lt;/p&gt;

&lt;p&gt;My first fix for this, on an earlier pass, was to give the optimistic side its own reset branch too, mirroring the reset-signal pattern useActionState's own docs recommend. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;commentReducer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks reasonable and it's wrong in a way that only shows up once you trace what &lt;code&gt;current&lt;/code&gt; actually holds. &lt;code&gt;current&lt;/code&gt; isn't just the pending draft, it's the whole list, confirmed comments included. Returning &lt;code&gt;[]&lt;/code&gt; unconditionally means clicking Reset would briefly wipe out comments that had already been saved, not just cancel whatever hadn't gone through yet. It settles back once the Transition resolves, since &lt;code&gt;comments&lt;/code&gt; itself never changed, but for a moment the list a real user is looking at goes empty for no reason they'd understand.&lt;/p&gt;

&lt;p&gt;The actual fix was realizing useOptimistic doesn't need a reset branch here at all. By the time Reset is even clickable, &lt;code&gt;isPending&lt;/code&gt; is false, which means nothing is pending, which means &lt;code&gt;optimisticComments&lt;/code&gt; already equals &lt;code&gt;comments&lt;/code&gt;. There's nothing left for a reset to clear. The only state that genuinely needed a manual way to reset was useActionState's own return value, the error message, and that's exactly what &lt;code&gt;formAction(null)&lt;/code&gt; handles in &lt;code&gt;handleReset&lt;/code&gt; above. One piece of state actually needed telling to reset. The other one already had, on its own, which is the whole point of how useOptimistic works in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resetting while an action is pending, and the AbortController pattern I got wrong
&lt;/h2&gt;

&lt;p&gt;There's still a real question buried in here: what happens if Reset gets triggered while a submission is still in flight?&lt;/p&gt;

&lt;p&gt;In an earlier draft I said React doesn't provide a cancellation mechanism for this. That was wrong, and worth correcting properly instead of quietly editing around it. The current useActionState reference has a section called "Cancelling queued Actions" that threads an &lt;code&gt;AbortController&lt;/code&gt; through the payload passed to &lt;code&gt;dispatchAction&lt;/code&gt;, letting a new dispatch abort whatever's still pending so it can run immediately instead of waiting in line. It's a documented recipe, not something you'd have to invent from scratch.&lt;/p&gt;

&lt;p&gt;What I said next holds up better. Whether you should actually reach for that pattern depends on what the pending action does. The docs are blunt about it: aborting an Action isn't always safe, because cancelling the request client-side doesn't undo a mutation that already landed on the server. If &lt;code&gt;saveComment&lt;/code&gt; already wrote to the database by the time an abort fires, the comment is still there no matter what the client thinks happened.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;handleReset&lt;/code&gt; above never reaches for &lt;code&gt;AbortController&lt;/code&gt;. It calls &lt;code&gt;formAction(null)&lt;/code&gt; the same way it calls every other dispatch, and because useActionState processes calls to &lt;code&gt;dispatchAction&lt;/code&gt; in the order they arrive, the reset just queues behind whatever's still running instead of racing it. Disabling the Reset button while &lt;code&gt;isPending&lt;/code&gt; is true isn't there to prevent a bug. It's there so the person clicking it isn't left wondering why nothing happened yet.&lt;/p&gt;

&lt;p&gt;If you're building something where cancelling mid-flight genuinely matters, a search-as-you-type action where a stale result arriving late would actively confuse the user is a good example, that AbortController pattern is worth reaching for. For a mutation like posting a comment, letting it finish is the safer default, and as it turns out, the simpler one too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;useOptimistic asks very little of you on its own: show a value, let it fall back automatically. The two mistakes I walked through here both came from not trusting that. Forgetting to close the loop with &lt;code&gt;onConfirmed&lt;/code&gt; meant the hook had nothing real to fall back to. Giving it its own reset branch meant fighting a job it was already doing correctly by itself. Trust the parts that are automatic, wire up the parts that aren't, and reset only the state that actually needs telling.&lt;/p&gt;

&lt;p&gt;If you haven't read the full breakdown of useOptimistic, including the mistake that makes optimistic updates snap back even on success and when to reach for a reducer instead of an updater function, that's here: &lt;a href="https://shubhra.dev/tutorials/react-19-useoptimistic" rel="noopener noreferrer"&gt;React 19 useOptimistic Explained&lt;/a&gt;. And if you're doing this inside a real Next.js app with Server Actions, cache invalidation, and error boundaries in the mix, the &lt;a href="https://shubhra.dev/tutorials/nextjs-16-useoptimistic-rollback-pattern" rel="noopener noreferrer"&gt;rollback pattern deep dive&lt;/a&gt; covers the parts that only show up once you're past the demo.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>React 19's useActionState Showed Me Why Disabling My Submit Button Was Never Enough</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 28 Jul 2026 10:50:54 +0000</pubDate>
      <link>https://dev.to/shubhradev/react-19s-useactionstate-showed-me-why-disabling-my-submit-button-was-never-enough-53jd</link>
      <guid>https://dev.to/shubhradev/react-19s-useactionstate-showed-me-why-disabling-my-submit-button-was-never-enough-53jd</guid>
      <description>&lt;p&gt;Every form I ever shipped before React 19 needed the same three pieces of state, and I wired them up by hand every single time.&lt;/p&gt;

&lt;p&gt;One for the result. One for whether it's submitting. One for the error. And at least once a sprint, I'd forget to reset one of them in the right place and spend twenty minutes staring at a button that wouldn't re-enable.&lt;/p&gt;

&lt;p&gt;Here's the version I wrote for years, probably the same one you have sitting in a dozen components right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OldSignupForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isSubmitting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsSubmitting&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;setIsSubmitting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setIsSubmitting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three state variables. A try/catch/finally. And one bug waiting to happen the moment someone forgets that &lt;code&gt;finally&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I didn't think this pattern was a problem until I actually sat down with &lt;code&gt;useActionState&lt;/code&gt; and realized the workaround I'd been shipping to "fix" double submits was never actually fixing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The habit that felt safe but wasn't
&lt;/h2&gt;

&lt;p&gt;Here's what I mean. Most of us migrate a form like this by adding some version of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleClick&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setLocalPending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// feels safe, isn't tied to anything real&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a fast connection, you'll never notice this is broken. On a slow one, that local flag can flip back to false before the actual request has resolved. A user on spotty wifi taps "Place Order" twice, maybe three times, because the button looked re-enabled for a split second. Each tap still goes through. React doesn't drop them, and it doesn't race them either. It queues them and runs them one after another, in order. So instead of one order, you've got three, each one processed like it was intentional.&lt;/p&gt;

&lt;p&gt;That was the moment &lt;code&gt;useActionState&lt;/code&gt; stopped feeling like just another hook to memorize. It started feeling like React quietly admitting they had watched enough of us get this wrong and finally built the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You give it a function, React calls that function with &lt;code&gt;(previousState, formData)&lt;/code&gt; whenever the form submits, and whatever it returns becomes your new &lt;code&gt;state&lt;/code&gt;. &lt;code&gt;isPending&lt;/code&gt; is tied to React's own transition tracking, not a boolean you're guessing the timing of.&lt;/p&gt;

&lt;p&gt;A working example, no framework required, plain React:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useActionState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Enter a valid email.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You're subscribed.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;NewsletterForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"you@example.com"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isPending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Subscribing...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Subscribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;onSubmit&lt;/code&gt;. No &lt;code&gt;preventDefault&lt;/code&gt;. No separate pending flag to keep in sync. Swap &lt;code&gt;disabled={isPending}&lt;/code&gt; in for that fake local flag, and the double-submit problem you thought you'd solved actually gets solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that genuinely surprised me
&lt;/h2&gt;

&lt;p&gt;I assumed rapid clicks would race each other. Maybe the last one wins, maybe the first one does, classic race-condition territory. That's not what happens.&lt;/p&gt;

&lt;p&gt;React queues every call to the action function and runs them sequentially. Each one waits for the previous one to finish before it starts. Click "Add to Cart" four times fast and it takes roughly four times as long to settle, not because anything's broken, but because call two is patiently waiting for call one's promise to resolve first. Nothing races. Nothing gets silently dropped.&lt;/p&gt;

&lt;p&gt;Which means the real problem was never a corrupted database write. The problem is that every click still counts. Five taps on a slow connection means five processed actions, not one. &lt;code&gt;disabled={isPending}&lt;/code&gt; isn't there to prevent a race, since there isn't one. It's there to stop someone from queuing up four actions they never meant to trigger in the first place.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing before it bites you. If an earlier call in that queue throws, React skips every call still waiting behind it. Catch your errors and return a state object instead of letting anything throw, or you'll lose queued actions you never even knew were sitting there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong. Try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two more traps I walked straight into
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stale closures.&lt;/strong&gt; If your action function reaches into component-scoped variables instead of pulling straight from &lt;code&gt;formData.get(...)&lt;/code&gt;, you're one re-render away from acting on data that's already out of date. Prefer reading submitted values from &lt;code&gt;formData&lt;/code&gt; instead of relying on values captured from an earlier render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting there's no reset button.&lt;/strong&gt; &lt;code&gt;useActionState&lt;/code&gt; has no built-in way to clear its own state. The docs say so plainly. If you need a "start over" button, you have two real options. Teach your action function to recognize a reset signal as one of its inputs, or change the component's &lt;code&gt;key&lt;/code&gt; prop to force a full remount. The reset-signal route is usually the less disruptive one, since it doesn't tear the DOM down to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I don't reach for it
&lt;/h2&gt;

&lt;p&gt;If the interaction needs to feel instant, think a like button or a checkbox, anything where the UI should update before the server has even responded, &lt;code&gt;useActionState&lt;/code&gt; will always feel a beat too slow for that. That's &lt;code&gt;useOptimistic&lt;/code&gt;'s job, and it's worth pairing the two once you've got the basics down.&lt;/p&gt;

&lt;p&gt;And if it's a multi-step wizard with controlled inputs across steps that aren't all mounted at once, fighting the uncontrolled-form model &lt;code&gt;useActionState&lt;/code&gt; is built on usually costs you more code than it saves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual takeaway
&lt;/h2&gt;

&lt;p&gt;The pattern you've probably been shipping, disable on click and hope for the best, was hiding the real issue instead of solving it. Every click still lands whether the button looks disabled or not. &lt;code&gt;useActionState&lt;/code&gt; doesn't just cut boilerplate. It ties your pending state to something real, so the thing you thought you fixed actually gets fixed.&lt;/p&gt;

&lt;p&gt;I wrote the full breakdown on my site, including the complete production signup example with field-level validation, the side-by-side comparison table against &lt;code&gt;useState&lt;/code&gt; and &lt;code&gt;useFormStatus&lt;/code&gt;, and the accessibility details around &lt;code&gt;aria-busy&lt;/code&gt; and disabling inputs during a pending state: &lt;a href="https://shubhra.dev/tutorials/react-19-useactionstate" rel="noopener noreferrer"&gt;React 19 useActionState Explained&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I honestly thought I understood this pattern until I started testing it properly. It turned out the problem wasn't React at all. It was the way I'd been managing pending state for years.&lt;/p&gt;

&lt;p&gt;If you've been relying on your own pending state to handle form submissions, it's worth taking another look. I know I was surprised by what actually happens.&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Optimistic UI Race Condition That Only Showed Up on the Fifth Click</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 21 Jul 2026 10:24:35 +0000</pubDate>
      <link>https://dev.to/shubhradev/the-optimistic-ui-race-condition-that-only-showed-up-on-the-fifth-click-5a55</link>
      <guid>https://dev.to/shubhradev/the-optimistic-ui-race-condition-that-only-showed-up-on-the-fifth-click-5a55</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I originally shared this debugging story on July 8. When Bug Smash was announced, it immediately came to mind because nothing crashed, nothing logged an error, and the application was still wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Everything worked. I'd wired up &lt;code&gt;useOptimistic&lt;/code&gt; on a task list, the checkbox flipped the instant you clicked it, no spinner, no half second lag, exactly the feeling I was going for. I demoed it to myself a dozen times and moved on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5rimzq7q866kvgphp6f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5rimzq7q866kvgphp6f.png" alt="Finish invoice checkbox checked with strikethrough after an optimistic update" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That was the trap. Optimistic UI is designed to look right immediately. Looking right and being right are not the same claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment I stopped trusting the demo
&lt;/h2&gt;

&lt;p&gt;Then I did the thing you're supposed to do before you actually ship anything. I clicked it stupidly fast, five times in a row, the way an impatient real user actually would.&lt;/p&gt;

&lt;p&gt;The checkbox stayed visually fine. The database did not. Five overlapping requests had gone out, each one flipping the same boolean, landing in whatever order the network felt like that day. Nothing crashed. Nothing logged an error. The UI just quietly stopped matching what actually happened on the server, and I had no way of knowing from looking at the screen.&lt;/p&gt;

&lt;p&gt;That is the bug that is easy to miss with optimistic UI specifically, because the whole point of optimistic UI is that it looks right immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix isn't anything specific to useOptimistic
&lt;/h2&gt;

&lt;p&gt;It's the same rule as disabling a submit button while a form is in flight, just applied per row instead of per form. Track which item has a request pending, and disable it until that request settles.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pendingId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPendingId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleToggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setPendingId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setOptimisticTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toggleTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setPendingId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"checkbox"&lt;/span&gt;
  &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pendingId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleToggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One item at a time, scoped per row, not a global lock on the whole list. The second click on the same checkbox now does nothing instead of firing a second request that races the first one to the database.&lt;/p&gt;

&lt;p&gt;Here's what that actually looks like: five rapid clicks, one request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Famgz4cylrxn9rmsh3ddz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Famgz4cylrxn9rmsh3ddz.png" alt="Browser Network tab showing only one POST request after five rapid clicks" width="800" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One comment made me think harder
&lt;/h2&gt;

&lt;p&gt;After the original post went up, Nazar Boyko left a comment that stuck with me.&lt;/p&gt;

&lt;p&gt;He pointed out something I hadn't really separated in my own head. Disabling the checkbox only blocks clicks that go through that specific input. If the same toggle can ever be triggered another way, like a keyboard shortcut or somewhere else in the app, the disabled attribute is only one layer of protection. The real guard is the id keyed pending state, because that prevents the same operation from starting again regardless of how it was triggered.&lt;/p&gt;

&lt;p&gt;He also mentioned that the rollback explanation was the part that clicked for him. Most examples manually flip the state back in a &lt;code&gt;catch&lt;/code&gt; block without explaining why. Realizing that a plain toggle doesn't need that extra step was one of the biggest things I learned while digging into &lt;code&gt;useOptimistic&lt;/code&gt;, so it was nice to hear that it helped someone else too.&lt;/p&gt;

&lt;p&gt;That's one of my favorite things about writing technical posts in public. Sometimes someone doesn't find a bug in your code. They help you explain the idea more clearly or think about it from a different angle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rollback question that sent me down a rabbit hole
&lt;/h2&gt;

&lt;p&gt;While I was in there, I went looking for how to properly roll back an optimistic update on failure, since obviously if the toggle fails, the checkbox needs to snap back to whatever it actually was.&lt;/p&gt;

&lt;p&gt;Almost every example I found does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setOptimisticTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;toggleTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setOptimisticTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// flip it back again to "undo"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. It's also solving a problem that, for a plain toggle like this one, does not need solving. &lt;code&gt;useOptimistic&lt;/code&gt; does not give you a second, independent piece of state you own. It gives you a temporary value layered on top of whatever state you passed in, for exactly as long as the transition is pending. The moment that transition settles, success or failure, React drops the temporary layer and renders from the real state again. If the real state never changed, because the request failed and nothing re-fetched, the checkbox reverts on its own. No second dispatch required.&lt;/p&gt;

&lt;p&gt;There is one real exception. If the optimistic value is something the server has not confirmed yet at all, a new comment you added with a placeholder id before the database assigned a real one, there is no prior version of that item sitting in your base state to fall back to. A failed insert does not revert a flag, it has to remove something that only ever existed on the client. That is the one case where you keep your own local record of what you added and clear it yourself in the catch block. A toggle does not have that problem, since the value it flips already exists on the server either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache function question I didn't expect to have opinions about
&lt;/h2&gt;

&lt;p&gt;Once the toggle worked, the next question was how to actually invalidate the cache after it. Next.js 16 gives you three ways to do this, and picking the wrong one either shows the user stale data or forces every page on the site to block on a single write.&lt;/p&gt;

&lt;p&gt;For a checkbox someone is staring at as they click it, &lt;code&gt;updateTag&lt;/code&gt; is the right one, since it expires the tag immediately so the page the user is currently on reflects their own write right away. If that same task count also fed a sidebar stat somewhere else on the site, one nobody is watching in real time, &lt;code&gt;revalidateTag&lt;/code&gt; on that same tag lets it catch up a beat later instead.&lt;/p&gt;

&lt;p&gt;One thing worth knowing if you are on a recent Next.js 16 version, &lt;code&gt;revalidateTag&lt;/code&gt; now requires a second argument.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tasks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// recommended in Next.js 16+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;revalidateTag("tasks")&lt;/code&gt; alone is deprecated and will throw a TypeScript error.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2r74jryl0cpsl0omp3ya.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2r74jryl0cpsl0omp3ya.png" alt="TypeScript error showing revalidateTag requires two arguments in Next.js 16" width="800" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed for me
&lt;/h2&gt;

&lt;p&gt;I used to demo optimistic UI by clicking once.&lt;/p&gt;

&lt;p&gt;Now I deliberately try to break it. Five rapid clicks. Slow networks. Repeated interactions.&lt;/p&gt;

&lt;p&gt;Race conditions rarely announce themselves. They don't throw exceptions. They don't light up your console. They quietly wait for a user who is just a little more impatient than you were during development.&lt;/p&gt;

&lt;p&gt;That checkbox looked perfect every time I demonstrated it. The bug was there the whole time. It was simply waiting for me to test it like a real user.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you'd like the full walkthrough, including the rollback patterns, cache decisions, and the rest of the implementation, I've put it all here: &lt;a href="//shubhra.dev/tutorials/nextjs-16-useoptimistic-rollback-pattern"&gt;shubhra.dev/tutorials/nextjs-16-useoptimistic-rollback-pattern&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>nextjs</category>
      <category>react</category>
    </item>
  </channel>
</rss>
