<?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>The Garden I Wait for Every Winter</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Sun, 18 Oct 2026 12:21:49 +0000</pubDate>
      <link>https://dev.to/shubhradev/the-garden-i-wait-for-every-winter-8mg</link>
      <guid>https://dev.to/shubhradev/the-garden-i-wait-for-every-winter-8mg</guid>
      <description>&lt;p&gt;October always makes me look at my garden a little differently.&lt;/p&gt;

&lt;p&gt;The monsoon is beginning to fade, the mornings are changing, and I'm already thinking about winter. Not just the cooler weather, but the winter garden I want to create over the next few months.&lt;/p&gt;

&lt;p&gt;I've started preparing some of the pots and thinking about what I want to grow. Petunias are always somewhere in my plans. I use them a lot, and I never seem to get tired of finding another place where they might work. Alyssum usually finds its way into my ideas too.&lt;/p&gt;

&lt;p&gt;Then there are all the little decisions. Which plants would look good together? Where should the brighter flowers go, and where would a quieter combination of greenery and softer colours work better? Sometimes I've imagined an entire arrangement before I've even bought the plants. Nothing has really changed yet, but in my head, the garden is already beginning to look different.&lt;/p&gt;

&lt;h2&gt;
  
  
  A winter garden full of colours and little corners
&lt;/h2&gt;

&lt;p&gt;I don't want every part of my garden to look the same.&lt;/p&gt;

&lt;p&gt;Some corners can be full of petunias, with flowers spilling over the sides of their pots. Alyssum can soften another arrangement and fill the smaller spaces around other plants.&lt;/p&gt;

&lt;p&gt;Then there are the quieter corners. I like having ferns, snake plants, and coleus around too. They aren't there to replace the winter flowers. They give the garden something to look at between the blooms, with different leaf shapes, textures, and shades of green. Coleus is especially fun when I want foliage to bring some colour of its own.&lt;/p&gt;

&lt;p&gt;And then there is the edible side of the garden. I enjoy growing things that eventually make their way into the kitchen. Spinach and other leafy greens, radishes, carrots, lettuce, herbs, and strawberries all have their own place. They make the garden feel less like something I only look at and more like something I take part in.&lt;/p&gt;

&lt;p&gt;I don't need every corner to do the same job. One can be bright and full of flowers, another mostly foliage, while somewhere else I might have vegetables growing in a pot that I check every morning. That mix is what makes the garden feel like mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arranging plants in pots
&lt;/h2&gt;

&lt;p&gt;I especially enjoy putting different plants together in one container. I don't always want a single plant sitting alone in a pot. I like combining different shapes, heights, colours, and growing habits so the arrangement changes as you look at it.&lt;/p&gt;

&lt;p&gt;For this I like the thriller, filler, and spiller approach. Something taller gives the pot some height, fuller plants fill the space around it, and trailing plants spill over the edge. Petunias are wonderful for that last part once they start growing over the sides. Alyssum can fill smaller gaps and soften the edges, while a taller plant gives the whole pot some structure. I enjoy picturing all of that before I start planting.&lt;/p&gt;

&lt;p&gt;Of course, plants rarely follow the picture in my head. One grows faster than another. Something needs more room than I expected. A plant that looked perfectly placed when I first arranged the pot can look crowded a few weeks later.&lt;/p&gt;

&lt;p&gt;So I move things around.&lt;/p&gt;

&lt;p&gt;Sometimes changing the position of one plant makes the whole pot look different. Sometimes I swap one companion for another. And sometimes the combination I didn't expect to work becomes the one I like most. That's what I enjoy about arranging pots. There's always another combination I want to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Between coding and gardening
&lt;/h2&gt;

&lt;p&gt;Most of my working day is spent coding, and when I'm working, I like being completely focused on it. I enjoy getting deep into a problem, understanding how the pieces fit together, and staying with it until I find a solution. Gardening gives me something completely different to focus on.&lt;/p&gt;

&lt;p&gt;When I step away from my laptop, I might go outside, look at the pots, and notice something that needs attention. An empty pot might give me an idea. A plant that has grown more than I expected might make me rethink the corner around it. Sometimes I don't even do anything. I just walk around and look.&lt;/p&gt;

&lt;p&gt;There is a small similarity to designing something before building it in code. I think about the different pieces, imagine how they might fit together, try a few possibilities, and sometimes change the whole plan before I start.&lt;/p&gt;

&lt;p&gt;But the garden has one rule that software doesn't: I can't make it happen faster just because I'm ready. I can prepare the soil, choose the plants, arrange the pots, and give everything the space it needs. After that, the plants take over. A petunia doesn't bloom because I'm impatient to see the final arrangement. A spinach plant doesn't grow faster because I keep checking whether it is ready to harvest.&lt;/p&gt;

&lt;p&gt;They take their time. And I like that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting ready for the season
&lt;/h2&gt;

&lt;p&gt;For me, winter gardening isn't only about the garden when it is finally full of flowers. I enjoy everything that comes before that too.&lt;/p&gt;

&lt;p&gt;There are pots to clean, soil to refresh, plants to choose, and arrangements to rethink. I get to decide what I want to try this year without knowing exactly how it will all turn out. Some combinations will work the way I imagined. Some won't.&lt;/p&gt;

&lt;p&gt;A plant might grow in an unexpected direction. Something I thought would be a small part of an arrangement might become the thing I notice most. A simple combination might turn out better than the complicated one I spent much longer planning. Sometimes a plant just doesn't cooperate with my plan, and that's fine. I can move it, give it more space, pair it with something else, or try a different spot. The garden doesn't mind if I change my mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  A little space to create something of my own
&lt;/h2&gt;

&lt;p&gt;I've already started shaping a few corners into what I imagined. Some days I spend more time with the flowering plants. Other days I find myself looking at the spinach, the herbs, or the strawberries and noticing how much they've changed. That's one thing I love about growing so many different kinds of plants. There is almost always something happening somewhere.&lt;/p&gt;

&lt;p&gt;Eventually, I'll come back inside, clean up, open my laptop, and return to my work. I may not come back with a brilliant new idea. Maybe I'll simply feel happy about the little corner I've started creating.&lt;/p&gt;

&lt;p&gt;And I think that's enough.&lt;/p&gt;

&lt;p&gt;Not everything has to become a lesson. Sometimes it's enough to step away from a screen, spend time doing something I love, and watch it grow at its own pace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about you? Is there something outside your work that you look forward to, even before it begins?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>watercooler</category>
      <category>discuss</category>
      <category>gardening</category>
      <category>devlive</category>
    </item>
    <item>
      <title>Your Auth Check Passed. The Agent Still Did Something Nobody Asked For</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 13 Oct 2026 07:30:23 +0000</pubDate>
      <link>https://dev.to/shubhradev/your-auth-check-passed-the-agent-still-did-something-nobody-asked-for-156f</link>
      <guid>https://dev.to/shubhradev/your-auth-check-passed-the-agent-still-did-something-nobody-asked-for-156f</guid>
      <description>&lt;p&gt;Authentication answers who. Authorization answers what they're allowed to do. Neither one answers whether this specific action was actually intended.&lt;/p&gt;

&lt;p&gt;That's the gap I wanted to test. I built a small AI agent and tested it against several failure cases before I got to a result I was confident in. I'm including that part too, because pretending everything worked on the first try would be its own kind of false claim.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: &lt;a href="https://arcjet.com" rel="noopener noreferrer"&gt;Arcjet&lt;/a&gt; commissioned this article. I chose the angle, wrote the code, and ran the tests independently. Arcjet reviewed the technical outline for factual accuracy, not the conclusions.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The authorization gap in AI agents
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/shubhradev/nextjs-16-server-actions-security-the-auth-check-most-developers-miss-1ei1"&gt;Earlier in this series&lt;/a&gt; I made the case that a Server Action isn't a function call, it's a public POST endpoint wearing a convenient syntax. That post was about who can reach the action and whether they're allowed to. This one is about a question a model changes entirely. Once a model is deciding whether to call that action, being "allowed to" stops being the whole story.&lt;/p&gt;

&lt;p&gt;A human clicking a button is one kind of signal. A model generating a tool call from the contents of a document is a different kind of signal, and none of the checks from that earlier post, not the ownership check, not the role check, not the proxy in front of it, were built to tell the two apart. That's not a flaw in that post's argument. It's a different question the three-layer model was never asked to answer.&lt;/p&gt;

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

&lt;p&gt;I built a small Next.js research assistant. It reads a document, summarizes it, and if the document mentions filing an expense, it calls a &lt;code&gt;fileExpenseReport&lt;/code&gt; tool to do it. Nothing fancier than that on purpose. I wanted the security behavior easy to reason about, not buried under unrelated complexity.&lt;/p&gt;

&lt;p&gt;The stack: Vercel AI SDK, Gemini as the model, &lt;a href="https://docs.arcjet.com/guards/vercel-ai" rel="noopener noreferrer"&gt;Arcjet's Guard SDK&lt;/a&gt; wired in at two separate points:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;launchArcjet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detectPromptInjection&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;@arcjet/guard&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;guardTool&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;@arcjet/guard/vercel-ai/v7&lt;/span&gt;&lt;span class="dl"&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;detectPromptInjection&lt;/code&gt; screens the inbound document before the model ever sees it. &lt;code&gt;guardTool&lt;/code&gt; wraps the actual tool call. They're two separate enforcement points answering two different questions, not one check doing double duty. Collapsing them into a single check is the mistake this whole piece is about, so I kept them apart on purpose.&lt;/p&gt;

&lt;p&gt;Everything below is real output from my own account. Synthetic accounts, synthetic data, real Arcjet decisions. The "database" is a stub that logs each insert to the console, so a "database write" here means a call to that stub.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legitimate baseline
&lt;/h2&gt;

&lt;p&gt;First run: a normal document, a normal expense, nothing adversarial. The model read it, called the tool, and the database write went through. That's the control case, the thing that has to keep working while everything else gets locked down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack one: the obvious injection
&lt;/h2&gt;

&lt;p&gt;Next, I planted a document with a direct embedded instruction, something that told the agent to file an expense to an account nobody had actually asked about. &lt;code&gt;detectPromptInjection&lt;/code&gt; caught it and denied the request before the model ever got a turn. The Arcjet dashboard showed the same event independently, marked &lt;code&gt;PROMPT_INJECTION&lt;/code&gt;, sitting among otherwise clean entries.&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%2F80s4lxw3sd7y4w1v82o1.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%2F80s4lxw3sd7y4w1v82o1.png" alt="Arcjet Activity log showing a document.received request denied with reason PROMPT_INJECTION among otherwise allowed entries" width="799" height="263"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One thing from an earlier, less finished run of the same fixture: the injection got past detection once, and the model declined to act on its own. I'm not counting that as a security control. A model refusing to do something today doesn't mean it refuses tomorrow, and treating good judgment from an LLM as your actual security boundary is exactly the mistake I built this whole test to avoid making.&lt;/p&gt;

&lt;p&gt;A planted document is the version I actually built and tested, but it's one instance of a broader category. The same gap opens whenever the content driving a decision comes from somewhere the user didn't type it themselves, an email thread the agent summarizes, a page it scrapes, the output of a previous tool call feeding into the next one. I only have real results for the document case. I'm not going to claim I tested the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack two: the one that actually mattered
&lt;/h2&gt;

&lt;p&gt;The obvious injection is the easy case. The interesting one is the paraphrased version, no "ignore previous instructions," no imperative override, just the same underlying request phrased like an ordinary business note.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;detectPromptInjection&lt;/code&gt; let it through. The model attempted the tool call. And the action boundary, a plain allow-list check inside the tool's own &lt;code&gt;execute()&lt;/code&gt; handler, rejected the account and left no database write behind.&lt;/p&gt;

&lt;p&gt;That contrast is the center of the piece. Detection and authorization answer different questions. A detector asks: does this content look suspicious? The tool boundary has to ask something else entirely: should this specific call, with these specific arguments, actually run right now? A clean detection result doesn't answer that second question, and building your security around the assumption that it does is how paraphrased attacks get through.&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%2F3eppsn0tr6wfk1i8cx7w.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%2F3eppsn0tr6wfk1i8cx7w.png" alt="Terminal output showing the baseline request executing, the obvious injection blocked at injection screening, and the paraphrased injection reaching the tool but being denied at the action boundary with no database write" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorize the AI tool call before the side effect
&lt;/h2&gt;

&lt;p&gt;The check that stopped this particular attack is deliberately boring: a plain application-level allow-list inside the tool's own &lt;code&gt;execute()&lt;/code&gt; function. Worth being precise here since it's easy to conflate the two: &lt;code&gt;guardTool&lt;/code&gt; is the Arcjet wrapper around the tool call, and the allow-list below is my own application logic. It only runs once a call has already gotten past &lt;code&gt;guardTool&lt;/code&gt;, sitting immediately before the database write as its own separate, deterministic check.&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="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;ALLOWED_ACCOUNTS&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&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;denied&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;boundary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account_not_allowed&lt;/span&gt;&lt;span class="dl"&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="s2"&gt;`Account &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not on the allow-list`&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;I return a structured result instead of throwing. The function exits before the database write, and the result gives the model a clear explanation for the denial. If the model retries, the same allow-list check runs again.&lt;/p&gt;

&lt;p&gt;No exotic API, no clever abstraction. That's the point. The check doesn't need to be sophisticated. It needs to sit in the right place: after detection, after the model's decision, immediately before the side effect.&lt;/p&gt;

&lt;p&gt;A tool call is a proposal from the model. A successful side effect is a decision made by the application. Those are not the same event, and treating them as the same event is exactly how a clean-looking request turns into an unauthorized database write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-running the full matrix
&lt;/h2&gt;

&lt;p&gt;I confirmed the pattern held across the rest of the set: a benign document with imperative-sounding language that shouldn't trigger a false positive, and it didn't, no tool call attempted at all. A clean document with an ambiguous instruction, no injection pattern present anywhere, still denied at the action boundary. That last one matters on its own, it proves detection alone wouldn't have caught it, because there was nothing for a detector to catch.&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%2Falxuzajkpfa4cd5vli5i.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%2Falxuzajkpfa4cd5vli5i.png" alt="Terminal output showing the benign false-positive check producing no tool call, and the clean-content ambiguous-instruction case reaching the tool but denied at the action boundary" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual layers don't close this gap
&lt;/h2&gt;

&lt;p&gt;None of this replaces a proxy, a WAF, or RBAC. It's not a substitute for any of them. But it's worth being precise about what each one actually answers. A proxy or WAF sees a clean, authenticated POST request. RBAC correctly returns yes, this user is allowed to file expenses. Neither has any visibility into whether this particular call, with these particular arguments, was something the user actually intended. They're solving a different problem, and they solve it well. They just don't solve this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The remote-policy detour
&lt;/h2&gt;

&lt;p&gt;I also tested &lt;a href="https://docs.arcjet.com/guards/remote-policies" rel="noopener noreferrer"&gt;Arcjet's remote policies&lt;/a&gt;, which Arcjet documents as a way to change enforcement logic centrally without redeploying the application.&lt;/p&gt;

&lt;p&gt;The first runtime attempt didn't work cleanly. A config mismatch on my end needed fixing before the policy behaved as intended.&lt;/p&gt;

&lt;p&gt;With that corrected, and the local allow-list temporarily expanded so it couldn't be the thing making the decision, the same running application produced two results:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allowed account, policy in Live mode:&lt;/strong&gt; the model called the tool with the account the policy explicitly permits, Arcjet returned &lt;code&gt;ALLOW&lt;/code&gt;, and the database write happened.&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%2Ftpej35avihh5yfluikkr.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%2Ftpej35avihh5yfluikkr.png" alt="Arcjet Activity detail showing an ALLOW decision for the expense.filed policy, with accountId and amount as required inputs evaluated by Arcjet Cloud" width="542" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Denied account, same code, same deployment:&lt;/strong&gt; the model called the tool with a different account, and the response came back &lt;code&gt;DENY&lt;/code&gt;, reason &lt;code&gt;INPUT_CONSTRAINT&lt;/code&gt;, no error, no fallback behavior, a genuine policy match. Nothing wrote to the database.&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%2Frurfinb3yf7ujrm0yscq.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%2Frurfinb3yf7ujrm0yscq.png" alt="Arcjet Activity detail showing a DENY decision for the expense.filed policy, with the policy expression set to Deny in Live mode" width="539" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's what I wanted to verify with the remote policy: the decision about which accounts are allowed lived in Arcjet, not in my application code. The same running server, with no code changes, allowed one account and denied the other based on a policy I manage centrally. Per Arcjet's docs, editing that policy changes enforcement without a redeploy, though the SDK caches policies and rechecks them every few minutes, so changes are not instantaneous. I didn't test a policy edit between runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool call is not the same thing as executed
&lt;/h2&gt;

&lt;p&gt;This is worth stating on its own, because it's the core claim the whole article rests on. When you check whether a tool call happened, you're checking whether the model &lt;em&gt;attempted&lt;/em&gt; something. That's not the same as checking whether the underlying action &lt;em&gt;executed&lt;/em&gt;. A denied call can still show up as a tool call in your logs. In my test, it never reached the database write. The same distinction matters for whatever consequential operation your tool actually performs, whether that's a database write, an external API call, an email, or a payment. If your logging conflates those two things, you can end up believing a security check is working when it's actually just recording attempts, not outcomes.&lt;/p&gt;

&lt;p&gt;If a denied call still shows up in your logs, it's worth deciding upfront what you actually want recorded at that point:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fileExpenseReport&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DENY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;boundary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;account_not_allowed&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;Structuring it this way means an attempted-and-blocked call is unambiguous in your own telemetry, not something you have to reconstruct later by cross-referencing tool-call logs against database writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  PII is a different boundary
&lt;/h2&gt;

&lt;p&gt;Prompt-injection detection and authorization don't solve the same problem as sensitive data leaking to a model or a downstream tool call. If a document contains an email address, a phone number, or a government ID, checking the model's response is already too late, the data has already crossed the provider boundary.&lt;/p&gt;

&lt;p&gt;I didn't run a real test of this as part of this project's matrix, so I'm not presenting one as an experiment result. But it's worth naming precisely rather than skipping past it, because the boundary is different from everywhere else in this piece. It's not right before a side effect, it's wherever sensitive data is about to leave the application's control at all, a document going to the model provider, a tool output feeding back into model context, a value about to appear in a log.&lt;/p&gt;

&lt;p&gt;Arcjet's Guard SDK also provides local sensitive-info detection for agent and tool paths, so detection can happen in-process before sensitive content crosses another boundary. Detection tells you something sensitive is present. Redaction lets the model continue without the original value. Blocking is the right call when the value should never leave the application at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated traffic is a separate concern
&lt;/h2&gt;

&lt;p&gt;One more boundary worth mentioning, because it answers a genuinely different question than everything above. I sent a real &lt;code&gt;curl&lt;/code&gt; request to a small API route using the same Arcjet HTTP-boundary client, then sent the same request with its User-Agent changed to look like a normal browser. Both were denied as bots. Changing only the User-Agent didn't change the result. I can't say precisely which signals contributed to that decision, so I'm not going to guess at the mechanism.&lt;/p&gt;

&lt;p&gt;I'm keeping this separate because it's a different question. An authenticated, authorized, perfectly legitimate-looking request can still be automated and abusive at volume. Request-level bot detection and rate limiting belong at the HTTP boundary, alongside the application-level checks, not instead of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern doesn't help
&lt;/h2&gt;

&lt;p&gt;A legitimately authorized user asking for something genuinely bad isn't caught by any of this. That's not a prompt-injection problem, it's a business-authorization problem, and it's still squarely your application's responsibility to define who's allowed to do what.&lt;/p&gt;

&lt;p&gt;And the model's own judgment is not something to lean on as a security control, however good it looks in a given run. I saw a model decline to act on a bad instruction in one run and comply with an equivalent instruction it hadn't seen worded that way in another. That's exactly why the check needs to live in code that runs the same way every time, not in whatever the model happens to decide.&lt;/p&gt;

&lt;p&gt;Runtime enforcement isn't free either. Each additional check adds processing and operational complexity, and remote policy evaluation introduces another dependency that needs explicit failure handling. The right tradeoff depends on how consequential the operation is and how much latency the application can tolerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the experiments actually proved
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Legitimate request&lt;/td&gt;
&lt;td&gt;Tool called, database write succeeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Obvious injection&lt;/td&gt;
&lt;td&gt;Denied by detection, corroborated in the dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paraphrased injection&lt;/td&gt;
&lt;td&gt;Passed detection, denied at the action boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benign content, imperative language&lt;/td&gt;
&lt;td&gt;No tool call attempted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean content, ambiguous instruction&lt;/td&gt;
&lt;td&gt;Denied at the action boundary, with no injection pattern present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote policy, allowed account&lt;/td&gt;
&lt;td&gt;Evaluated, allowed, wrote to the database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote policy, denied account&lt;/td&gt;
&lt;td&gt;Evaluated and denied by the remote policy, with no database write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real automated request&lt;/td&gt;
&lt;td&gt;Denied as a bot; changing the User-Agent did not change the result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A practical pattern
&lt;/h2&gt;

&lt;p&gt;If you're building anything where a model can trigger a consequential action, put an explicit, application-level check immediately before that action for anything in this category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;money movement&lt;/li&gt;
&lt;li&gt;deleting data&lt;/li&gt;
&lt;li&gt;changing permissions&lt;/li&gt;
&lt;li&gt;sending external communications&lt;/li&gt;
&lt;li&gt;refunds&lt;/li&gt;
&lt;li&gt;production changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Authentication and authorization should still happen upstream, same as always. This check isn't a replacement for either. It's a specific, narrow boundary sitting right in front of the operation that actually causes harm if it's wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before an AI agent can cause a side effect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is the user authenticated?&lt;/li&gt;
&lt;li&gt;Is the user authorized for this resource?&lt;/li&gt;
&lt;li&gt;Is content influencing the model treated as untrusted?&lt;/li&gt;
&lt;li&gt;Is the tool call independently authorized?&lt;/li&gt;
&lt;li&gt;Are the actual tool arguments checked, not just the fact that a call happened?&lt;/li&gt;
&lt;li&gt;Does denial happen before the side effect?&lt;/li&gt;
&lt;li&gt;Can sensitive data leave the application?&lt;/li&gt;
&lt;li&gt;Can automated traffic reach the endpoint repeatedly?&lt;/li&gt;
&lt;li&gt;Have you verified that a denied call produces no side effect, not just a log entry?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The model doesn't own the security boundary
&lt;/h2&gt;

&lt;p&gt;Authentication tells you who's asking. Authorization tells you what they're generally allowed to do. Neither one tells you that this specific action, generated by a model, from content it read somewhere, was actually something anyone intended. That check belongs at the action itself, checked in code that behaves the same way on every run, not inferred from whether the request looked clean upstream or whether the model seemed to behave reasonably this time.&lt;/p&gt;

&lt;p&gt;Put the boundary where the side effect happens. Everything else is a proposal until then.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Built an AI Gardening App That Tells Me to Put the Laptop Down</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Sun, 11 Oct 2026 12:54:29 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-built-an-ai-gardening-app-that-tells-me-to-put-the-laptop-down-1o93</link>
      <guid>https://dev.to/shubhradev/i-built-an-ai-gardening-app-that-tells-me-to-put-the-laptop-down-1o93</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Coding is my thing. Gardening is a part of who I am.&lt;/p&gt;

&lt;p&gt;I have hundreds of pots, and I know my plants through the little things they show me. I notice which ones respond well to buried banana peels, which seem happier with used tea granules or old buttermilk, and which need my lunch-box system instead of raw kitchen scraps buried around them. I watch their leaves, new growth and flowers.&lt;/p&gt;

&lt;p&gt;I do not need an app to tell me how to care for my own plants. That understanding comes from spending time with them. But it made me think about how a tool could help someone who is still learning to notice these things.&lt;/p&gt;

&lt;p&gt;For a long time coding and gardening lived in different corners of my day. I would code for hours, then walk away from the screen to be with my plants, and the two never really met. This challenge gave me a reason to put them in the same room. So first, thank you to the DEV team and everyone behind it. You gave me the opportunity to write code about the thing I love most, and I enjoyed every hour of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I almost asked an AI
&lt;/h2&gt;

&lt;p&gt;On 30 September I moved my marigold out of a shady, bright corner and into the open sun. The corner was there to keep the heavy rain off it. It grew from a cutting of a plant that came up by itself from last year's seeds, so it already feels a little like family.&lt;/p&gt;

&lt;p&gt;On 5 October I counted about three open flowers and three buds. On 9 October I counted seven open flowers and five buds.&lt;/p&gt;

&lt;p&gt;My first thought was to ask an AI why.&lt;/p&gt;

&lt;p&gt;An answer would have been easy to get and easy to believe. But the sun, rain and time passing had all played a part, and I couldn't tell which one made the difference. Neither could an AI. So I asked a different question. Not "why is my plant doing this?" but "what should I look at before I change anything?"&lt;/p&gt;

&lt;p&gt;That question became Garden Trials.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Garden Trials is a small app that runs entirely on your own computer. The whole idea fits in one line, which is look first, change one thing, let the garden answer.&lt;/p&gt;

&lt;p&gt;I do not like screens near my plants. When I am with them I want my hands in the soil and my phone away. Sometimes a bloom looks so good that I click a photo, for myself and for other plant lovers. So I built the app the way I wish gardening tools worked. It talks to me before and after, and in the middle it asks me to put the laptop down and go and look.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I write a note about a plant and the day I saw it. A local Gemma model reads it into plain facts.&lt;/li&gt;
&lt;li&gt;The app asks me to check one specific thing on the plant. Until I write down what I found, it will not suggest anything.&lt;/li&gt;
&lt;li&gt;I either watch, or pick one change from a small fixed menu, ideally leaving a similar plant alone to compare.&lt;/li&gt;
&lt;li&gt;I count and take a photo. The app shows me the last photo so I can match the angle.&lt;/li&gt;
&lt;li&gt;I get a table, a chart, a verdict, an evidence level from 0 to 3, and one plain next step.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is not a plant doctor. It never says what is wrong with a plant, and it never says what caused a result.&lt;/p&gt;

&lt;p&gt;Garden Trials is for gardeners who are still learning to read their plants, and for anyone who wants to check their assumptions rather than jump to conclusions. It brings observations, follow-up checks, simple experiments and evidence tracking into one process. Instead of just recording what happened, it helps gardeners look at what changed, what else might have affected the result, and what their observations can actually tell them. It is not meant to replace the understanding that grows from spending time with plants, but to encourage closer observation.&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%2F4ulpxxtjsd3r0evghhmm.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%2F4ulpxxtjsd3r0evghhmm.png" alt="The Look First box asking me to go and check the plant before anything else" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Rm7X4btlZVk" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The quickest way in is the sample garden. Tick "Try the illustrative sample garden" in the sidebar and you can walk through the whole loop with made-up plants. Nothing from it is ever saved to a real garden.&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%2Ff7u429hygfln118q33ba.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%2Ff7u429hygfln118q33ba.png" alt="The sample garden, clearly labelled as made up" width="800" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shubhrapokhariya" rel="noopener noreferrer"&gt;
        shubhrapokhariya
      &lt;/a&gt; / &lt;a href="https://github.com/shubhrapokhariya/garden-trials" rel="noopener noreferrer"&gt;
        garden-trials
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Local Gemma 3 1B app (Ollama + Streamlit) that sends gardeners back to their plants to look first, helps them test one change at a time, and assigns each result an evidence level from 0 to 3.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🌱 Garden Trials&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Look first. Change one thing. Let the garden answer.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A small local app for gardeners. A Gemma model running on your own laptop helps you write down what you
see and asks one thing to go and check on the plant. Plain Python builds the experiment, keeps the
records and works out how much the evidence really shows. The app never says what caused a result.&lt;/p&gt;
&lt;p&gt;Built for the Hacktoberfest Open-Source AI Challenge, Week 1: Touch Grass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Demo video (48 seconds, no sound):&lt;/strong&gt; &lt;a href="https://youtu.be/Rm7X4btlZVk" rel="nofollow noopener noreferrer"&gt;https://youtu.be/Rm7X4btlZVk&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quickstart&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Install &lt;a href="https://www.python.org/downloads/" rel="nofollow noopener noreferrer"&gt;Python 3.11 or newer&lt;/a&gt; and &lt;a href="https://ollama.com/download" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt;. The tests were run on Python 3.11.&lt;/li&gt;
&lt;li&gt;Download the code, then in its folder run:&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;python -m pip install -r requirements.txt
ollama pull gemma3:1b
python -m streamlit run app.py&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;On Windows you can double-click &lt;code&gt;run.bat&lt;/code&gt; instead. On macOS or Linux, run &lt;code&gt;bash run.sh&lt;/code&gt;. Both scripts
still start the app if Ollama is missing…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shubhrapokhariya/garden-trials" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Setup is in the README: Python 3.11 or newer, Ollama, &lt;code&gt;ollama pull gemma3:1b&lt;/code&gt;, then &lt;code&gt;python -m streamlit run app.py&lt;/code&gt;. The folder &lt;code&gt;examples/my-garden/&lt;/code&gt; holds my real notes, counts and photos from this week. Start the app with &lt;code&gt;GARDEN_DIR=examples/my-garden&lt;/code&gt; and you can read them without touching your own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  My real garden, this week
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Petunia
&lt;/h3&gt;

&lt;p&gt;On 7 October I counted two open flowers, one spent flower, four buds and a lot of tiny buds forming. I took photos and counts on 7, 8, 9 and 10 October, but I only opened the app on the 10th, so I typed the first three days in afterwards. The app marks those entries as backfilled, which is exactly what they are. It is also why the trial says it started on 10 October.&lt;/p&gt;

&lt;p&gt;When I saved the 7 October note, Gemma's reading dropped a number, so the app threw the reading away and kept my words exactly as I wrote them. Then Gemma's Look First question turned out to be about pests or disease, which I had never mentioned. The app blocked it and asked its own question instead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Count the open flowers and the buds right now. Do the newest shoots have buds?&lt;/p&gt;
&lt;/blockquote&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%2Fvfxqujudjeoag9o3d3bv.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%2Fvfxqujudjeoag9o3d3bv.png" alt="My note kept exactly as written. The app rejected Gemma's reading, so the line shown is my own words." width="800" height="624"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I went and looked on the 10th. Four open flowers, two bright ones on top and two darker ones lower down that had just opened, and small buds at the tips of the shoots.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Open flowers&lt;/th&gt;
&lt;th&gt;Buds&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7 Oct&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Front view. One spent flower not counted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 Oct&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Removed the spent flower. Photo from above.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9 Oct&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Buds clearly bigger and longer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 Oct&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Two buds opened. Bright sun, so the reds look brighter.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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%2Frqfggw4a2amxsme6a2nr.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%2Frqfggw4a2amxsme6a2nr.png" alt="Petunia on 7 October (left) and 10 October (right)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I removed one spent flower on the 8th. That was routine upkeep, not a test, but I want to say so. There was no comparison plant, and petunia buds open on their own anyway. The app gave this evidence level 0 and said it plainly. No change was made during these counts, so it shows what the plant did while I watched and says nothing about a cause.&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%2F0iqmpn5gc6qbrkc2r03b.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%2F0iqmpn5gc6qbrkc2r03b.png" alt="The petunia table and chart, with entries marked backfilled" width="800" height="630"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two new flowers opened darker and more velvety than the older ones. I read that a new petal is still small and tightly folded, so its colour is packed close together, and that as the flower opens fully and stretches out, the same colour spreads over a bigger petal and looks lighter. So I guess that is what I am seeing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Marigold
&lt;/h3&gt;

&lt;p&gt;The 5 October photo was taken for myself, not for the trial. I clicked it just because the plant looked lovely, from the side, and only later decided to use it here. One bud is partly cut off at the edge. The 9 October photo is the one I took for the trial, from above, where every flower and bud can be seen. My first count may have missed buds I could not see from the side, so the jump from 3 to 7 open flowers is in my notes, but it is not a fair comparison. A few petals also looked flattened after some rain. The rain may be the reason, but that is a guess.&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%2Fexbwc07x9iw52mwqy5kb.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%2Fexbwc07x9iw52mwqy5kb.png" alt="Marigold on 5 October from the side (left) and on 9 October from above (right)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Bougainvillea
&lt;/h3&gt;

&lt;p&gt;Five cuttings, about a month and a half old, in the same soil and the same small black nursery bags on the same bench. One of them already has two pink flowers and the other four have none. Its mother plant is in full bloom, while my other bougainvilleas are only starting. My guess, not tested, is the variety or the time of year. The cuttings are different varieties, so "same soil, same bench" is not a fair test. A real test needs cuttings of the same variety, taken at the same time from different mother plants.&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%2Frkqxghk9kj2ai5u1vxik.jpg" 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%2Frkqxghk9kj2ai5u1vxik.jpg" alt="Five bougainvillea cuttings, one with pink flowers" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The stack is small on purpose: &lt;a href="https://streamlit.io" rel="noopener noreferrer"&gt;Streamlit&lt;/a&gt;, one JSON file, and &lt;code&gt;gemma3:1b&lt;/code&gt; running locally through &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;. I picked the 1B &lt;a href="https://ai.google.dev/gemma" rel="noopener noreferrer"&gt;Gemma&lt;/a&gt; model to find out whether this idea works on an ordinary laptop with no cloud account. On a laptop CPU an answer can take up to a minute.&lt;/p&gt;

&lt;p&gt;Gemma has three jobs. It reads my note into plain facts, suggests one thing to go and check, and picks one change from a short menu. Everything that has to be reliable lives in plain Python, because I treat the model's output the way I would treat any untrusted input.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a number from my note is missing in Gemma's reading, the reading is rejected and my exact words are kept.&lt;/li&gt;
&lt;li&gt;Details Gemma adds that I did not write are removed by a word-match check. It is a loose check, so it can miss some.&lt;/li&gt;
&lt;li&gt;The cause field is forced to "unknown" in code.&lt;/li&gt;
&lt;li&gt;Pest and disease questions are blocked unless I raised them first.&lt;/li&gt;
&lt;li&gt;Trials come from a fixed menu that is filtered per plant. "Water more" is never offered for bougainvillea, which likes to stay on the dry side.&lt;/li&gt;
&lt;li&gt;Verdicts and evidence levels are calculated by code, never by Gemma. Level 3 needs two finished trials of the same change, each with a comparison plant and nothing else changed, all pointing the same way.&lt;/li&gt;
&lt;li&gt;Saves go to a temporary file first and are then swapped in. A garden file that cannot be read is moved aside and never overwritten.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests replace Gemma with fake and deliberately bad answers, such as garbage, dropped numbers, invented pests, off-menu picks. A second set drives the real pages end to end. There are 85 tests, and none of them needs Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The model did not behave in my own garden. Gemma's reading of all three of my notes was rejected by the number check, and one of its three questions was blocked.&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%2Fdkjv8ojwb2n4g5izh2zt.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%2Fdkjv8ojwb2n4g5izh2zt.png" alt="The sidebar panel showing three dropped numbers and one blocked question" width="343" height="883"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The other two questions got through, and they were generic. "Is the plant's stem straight?" had nothing to do with what I was seeing, so I wrote down what I actually noticed. The guards block invented pests. They cannot make a small model's question a good one. They also only catch the failures I thought of, and the checks match English words, so notes in Hindi are not checked the same way. In my real garden Gemma only read my notes and wrote Look First questions. Both of my real trials were watch-only, so it never picked a trial there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Gemma runs on my laptop through Ollama. There is no API key, no usage bill and no cloud AI call. My notes and photos sit in a folder on my own machine, and the photos are saved without their location data. Setup needs the internet once, to download the packages, Ollama and the model, and I switched off Streamlit's anonymous usage statistics.&lt;/p&gt;

&lt;p&gt;Running Gemma locally let me build and test this idea without an API key and without paying for every request. I could try it as often as I liked, and if a better small model arrives, I change one setting (&lt;code&gt;OLLAMA_MODEL&lt;/code&gt;) and the rest of the app stays as it is. The small model had clear limitations, which I describe above, but that was part of what I wanted to learn, which is how far an open model can take this idea on an ordinary laptop, with ordinary code checking its output. The experiment showed me both the value of running a model locally and the limits of relying on its answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;p&gt;The point of the app is what happens when I close the laptop. It asks me to go and look, and I stand there with the plant and nothing else. It cannot tell whether I really went outside, and I will not pretend it can. That pause is a promise I make to myself.&lt;/p&gt;

&lt;p&gt;The AI can suggest what to look at. The garden gives the answer.&lt;/p&gt;

&lt;p&gt;Thank you for reading, and thank you again to the DEV team for a challenge that let me bring the two things I love together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;Best Use of Gemma&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>gemma</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Aborting a Fetch Doesn't Stop Your Node.js Server. Here's What Does.</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 06 Oct 2026 08:47:00 +0000</pubDate>
      <link>https://dev.to/shubhradev/aborting-a-fetch-doesnt-stop-your-nodejs-server-heres-what-does-2fek</link>
      <guid>https://dev.to/shubhradev/aborting-a-fetch-doesnt-stop-your-nodejs-server-heres-what-does-2fek</guid>
      <description>&lt;p&gt;I fired 20 requests at a Node.js handler and made every client walk away after 100 ms. The handler was five steps of 200 ms each, so 100 steps of work in total if nothing got cancelled.&lt;/p&gt;

&lt;p&gt;Nothing got cancelled. The server finished &lt;strong&gt;100 of 100&lt;/strong&gt; steps for clients that were already gone.&lt;/p&gt;

&lt;p&gt;Then I taught the handler to notice the disconnect. Same 20 abandoned requests, and this time it ran &lt;strong&gt;0 of 100&lt;/strong&gt; steps.&lt;/p&gt;

&lt;p&gt;If your frontend calls &lt;code&gt;controller.abort()&lt;/code&gt; when a user switches a filter, your backend may be doing the same thing right now. The client-side abort is real, but it doesn't automatically reach the work your server is doing. I wanted to know exactly where it ends, so I tested &lt;code&gt;node:http&lt;/code&gt;, Express 4 and 5, Hono and a real PostgreSQL database. Node 22.22.2 and 24.21.0, &lt;code&gt;pg&lt;/code&gt; 8.23.1, PostgreSQL 18.4, HTTP/1.1, everything on localhost. I used Node's built-in &lt;code&gt;fetch&lt;/code&gt; as the client, not a browser.&lt;/p&gt;

&lt;p&gt;Three results surprised me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;node-postgres&lt;/code&gt; accepted a &lt;code&gt;{ signal }&lt;/code&gt; option and silently ignored it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;req.signal&lt;/code&gt; exists on Node 24 and is &lt;code&gt;undefined&lt;/code&gt; on Node 22, with no warning.&lt;/li&gt;
&lt;li&gt;Sending the Postgres cancel through your normal pool can hang it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the cancellation stops
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client        abort() -&amp;gt; fetch rejects, connection closes
server        sees a closed socket
your handler  keeps running
fetch to      keeps running
another API
SQL query     keeps running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the first two lines happen on their own. Everything below them is code you have to write. A synchronous CPU loop is a special case, and I'll get to it near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server sees when a client aborts
&lt;/h2&gt;

&lt;p&gt;I wrote a handler with five 500 ms steps and aborted from the client at 1200 ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1216ms] client: abort()
[1223ms] client: caught AbortError
[1225ms] server: res 'close' (writableEnded=false, destroyed=true)
[1548ms] server: step 3/5 done (socket.destroyed=true)
[2562ms] server: step 5/5 done (socket.destroyed=true)
[2567ms] server: res.end() called, no error thrown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client was done in 7 ms. The server noticed something, because the response emitted &lt;code&gt;close&lt;/code&gt;. But the handler is just an async function, and nobody told it to stop. Steps 3 to 5 ran against a dead socket, and &lt;code&gt;res.end()&lt;/code&gt; didn't even throw.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to detect a client disconnect in Node.js
&lt;/h2&gt;

&lt;p&gt;Listen for &lt;code&gt;close&lt;/code&gt; on the &lt;strong&gt;response&lt;/strong&gt;. The &lt;a href="https://nodejs.org/api/http.html" rel="noopener noreferrer"&gt;Node http docs&lt;/a&gt; say &lt;code&gt;ServerResponse&lt;/code&gt; emits it when the response finishes or when the connection drops before that. Inside the handler, check &lt;code&gt;res.writableFinished&lt;/code&gt; to tell the two cases apart. It was &lt;code&gt;true&lt;/code&gt; after a normal response and &lt;code&gt;false&lt;/code&gt; after an abort.&lt;/p&gt;

&lt;p&gt;I skip the request's &lt;code&gt;close&lt;/code&gt; event. It also fired on disconnect in my runs, but the docs only promise it means the request was completed, and I don't want to build on something that isn't guaranteed.&lt;/p&gt;

&lt;p&gt;This is the helper I ended up with. It merges "the client left" with a server-side deadline:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&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;gone&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;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&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;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;close&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="o"&gt;=&amp;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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;writableFinished&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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;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;client closed request&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;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;gone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&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;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&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;Pass the result to anything that takes a signal:&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;const&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&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;r&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="nx"&gt;upstreamUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the client left at 200 ms, my API stopped and the downstream request closed early. With the 600 ms deadline, the client got a 504 and the downstream request closed early too. &lt;code&gt;signal.reason&lt;/code&gt; tells you which case you're in: a &lt;code&gt;TimeoutError&lt;/code&gt; for the deadline, or your own error for a disconnect. In an earlier run with a hand-wired controller, the cancel travelled from the client through the API to the downstream service in about 25 ms.&lt;/p&gt;

&lt;p&gt;Two details that cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create the timeout signal inside the request. &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; starts counting when you call it, so a module-level one expires once and then fails every request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AbortSignal.timeout()&lt;/code&gt; rejects with &lt;code&gt;TimeoutError&lt;/code&gt;, not &lt;code&gt;AbortError&lt;/code&gt;. If your &lt;code&gt;catch&lt;/code&gt; only checks for &lt;code&gt;AbortError&lt;/code&gt;, timeouts slip through. Check &lt;code&gt;e.name&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;AbortSignal.any&lt;/code&gt; needs Node 20.3 or 18.17, and &lt;code&gt;AbortSignal.timeout&lt;/code&gt; needs 17.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Express have req.signal? It depends on your Node version
&lt;/h2&gt;

&lt;p&gt;Recent Node versions give &lt;code&gt;IncomingMessage&lt;/code&gt; a built-in &lt;code&gt;signal&lt;/code&gt;. Here is what I saw:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node 22.22.2&lt;/strong&gt;, &lt;code&gt;node:http&lt;/code&gt;, Express 4.22.3 and 5.2.1: &lt;code&gt;req.signal&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node 24.21.0&lt;/strong&gt;, same setups: it exists, and the handler stopped when the client left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hono 4.13.12&lt;/strong&gt; with &lt;code&gt;@hono/node-server&lt;/code&gt; 2.1.3: &lt;code&gt;c.req.raw.signal&lt;/code&gt; fired on both Node versions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Node docs list &lt;code&gt;req.signal&lt;/code&gt; as added in 24.16.0 and 26.1.0. There is a catch for the versions right after that. On 24.16 to 24.19 and 26.1 to 26.6, the signal can abort after a perfectly normal request finishes, which makes it unreliable for spotting a real disconnect. The fix landed in 24.20.0 and 26.7.0. If you run something in between, upgrade or test it yourself.&lt;/p&gt;

&lt;p&gt;The Node 22 case is the sneaky one. &lt;code&gt;sleep(ms, null, { signal: req.signal })&lt;/code&gt; passes &lt;code&gt;undefined&lt;/code&gt;, runs without any cancellation and throws nothing. The &lt;code&gt;res&lt;/code&gt; &lt;code&gt;'close'&lt;/code&gt; helper above behaves the same on every version I tried, which is why I kept using it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does aborting a fetch stop a running Postgres query?
&lt;/h2&gt;

&lt;p&gt;No. I ran &lt;code&gt;select pg_sleep(3)&lt;/code&gt; in a handler, aborted the client at 500 ms, and asked Postgres from a second connection what was still running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ 917ms] api: client left
[1227ms] DB: active pg_sleep queries 300ms after client left = 1
[3494ms] api: query finished normally
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres kept working for about 2.6 seconds after the client left. With a real report query, that means a held connection, wasted CPU and sometimes held locks.&lt;/p&gt;

&lt;h3&gt;
  
  
  node-postgres ignores the signal option
&lt;/h3&gt;

&lt;p&gt;The API shape makes this tempting:&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;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_sleep(2)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;pg&lt;/code&gt; 8.23.1, the query ran for 2009 ms and nothing was thrown. I also searched the installed &lt;code&gt;pg&lt;/code&gt;, &lt;code&gt;pg-pool&lt;/code&gt; and &lt;code&gt;pg-protocol&lt;/code&gt; code and found no &lt;code&gt;AbortSignal&lt;/code&gt; handling. Proposals exist in the node-postgres repo, but none of that was in the version I tested. So this is about &lt;code&gt;pg&lt;/code&gt; 8.23.1 only. It fails silently, so you only catch it by testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to cancel a Postgres query with pg_cancel_backend
&lt;/h3&gt;

&lt;p&gt;What worked was asking Postgres to cancel the running statement from a different connection, because the busy one is stuck inside the query:&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="c1"&gt;// the cancellation part only, not a complete handler&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestSignal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&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;client&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="nx"&gt;pid&lt;/span&gt; &lt;span class="p"&gt;}]&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_backend_pid() as pid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cancelPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_cancel_backend($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="nx"&gt;pid&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;console&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select pg_sleep(3)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// rejects with code 57014 when cancelled&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[5256ms] api: pg_cancel_backend sent
[5257ms] api: query error -&amp;gt; 57014 canceling statement due to user request
[5570ms] DB: active pg_sleep queries 300ms after client left = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I left the rest of the handler out on purpose, because a half-finished copy of it is worse than none. The full handler, plus the Node 22 vs 24 screenshots, is in my &lt;a href="https://shubhra.dev/tutorials/does-abortcontroller-cancel-server-work" rel="noopener noreferrer"&gt;longer write-up&lt;/a&gt;. The version I ran on Node 22 and 24 with Express 4.22.3 and 5.2.1 also does these things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It uses a separate pool for cancels. With a work pool of 2 and two abandoned requests at once, I reproduced a hang. Both connections were held by handlers waiting on a cancel that needed a free connection.&lt;/li&gt;
&lt;li&gt;It checks &lt;code&gt;signal.aborted&lt;/code&gt; right after the pid query. If the deadline already fired, the listener above never runs. The client is still waiting, so the handler answers 504 itself.&lt;/li&gt;
&lt;li&gt;It removes the listener and awaits any in-flight cancel before &lt;code&gt;client.release()&lt;/code&gt;. Otherwise the connection can go back to the pool mid-cancel.&lt;/li&gt;
&lt;li&gt;It catches &lt;code&gt;57014&lt;/code&gt; and reads &lt;code&gt;signal.reason&lt;/code&gt;. That code only means "cancelled". A deadline, a disconnect, &lt;code&gt;statement_timeout&lt;/code&gt; or a DBA can all cause it, and the answer should be 504 only for the deadline.&lt;/li&gt;
&lt;li&gt;It replies only if &lt;code&gt;!res.destroyed &amp;amp;&amp;amp; !res.writableEnded&lt;/code&gt; and sends everything else to &lt;code&gt;next(err)&lt;/code&gt;, since Express 4 doesn't catch rejected async handlers.&lt;/li&gt;
&lt;li&gt;Both pools have &lt;code&gt;on("error")&lt;/code&gt; handlers. Without them, one dropped idle connection can crash the process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat the cancel as best effort. &lt;code&gt;pg_cancel_backend&lt;/code&gt; returning &lt;code&gt;true&lt;/code&gt; means the signal was sent. The Postgres protocol docs say a cancellation might or might not have any effect, for example when it lands after the query already finished, and the client can't directly tell which happened. With a pool, only cancel while your request still owns the connection. It also does nothing for a session sitting idle in a transaction, since there is no running statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aborted doesn't mean undone
&lt;/h2&gt;

&lt;p&gt;I ran a transaction that did an &lt;code&gt;INSERT&lt;/code&gt;, spent one second on slow work, then &lt;code&gt;COMMIT&lt;/code&gt;, and let the client leave at 300 ms. The naive handler committed an order for a user who was long gone. The cancel-aware handler caught &lt;code&gt;57014&lt;/code&gt; and rolled back, so no row was written. The cancel only interrupts the statement. The rollback is yours to write.&lt;/p&gt;

&lt;p&gt;The mirror image happens on the client. If you abort a &lt;code&gt;POST&lt;/code&gt;, you can't know whether the server processed it, and a blind retry can create a duplicate. Put idempotency keys on anything with side effects.&lt;/p&gt;

&lt;p&gt;You don't have to wait for the client to leave, either. Postgres has &lt;code&gt;statement_timeout&lt;/code&gt;, and with 300 ms it raised &lt;code&gt;57014&lt;/code&gt; after 307 ms. That covers the opposite problem, clients that never disconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limit: a blocked event loop can't be cancelled
&lt;/h2&gt;

&lt;p&gt;JavaScript can't interrupt code that is already running. I ran a 1000 ms synchronous loop inside a handler and aborted the client at 200 ms. The server couldn't process the disconnect until the loop ended, and by then &lt;code&gt;res.writableFinished&lt;/code&gt; was &lt;code&gt;true&lt;/code&gt;. Node had written the response into a socket it hadn't yet noticed was closed, so even my own check couldn't tell the client had left.&lt;/p&gt;

&lt;p&gt;If the work blocks the loop, there is nothing to cancel. Move it to a worker thread, or cut it into chunks with an &lt;code&gt;await&lt;/code&gt; between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't test
&lt;/h2&gt;

&lt;p&gt;Drivers other than &lt;code&gt;pg&lt;/code&gt;, HTTP/2, reverse proxies and load balancers (a proxy may or may not tell your upstream that the client left), browsers, Deno and Bun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four rules I'd ship with
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Treat a disconnect as an event. Listen for &lt;code&gt;close&lt;/code&gt; on the response, check &lt;code&gt;writableFinished&lt;/code&gt;, and pass one signal to everything that accepts it.&lt;/li&gt;
&lt;li&gt;Cancel database work yourself when the driver won't. Check whether yours honors &lt;code&gt;{ signal }&lt;/code&gt; before you trust it, and keep cancels on their own pool.&lt;/li&gt;
&lt;li&gt;Put a deadline on every request with a per-request &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; plus &lt;code&gt;statement_timeout&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make side effects safe. Roll back on cancel and use idempotency keys for retries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The scripts behind every number are in a &lt;a href="https://github.com/shubhrapokhariya/abortcontroller-node-postgresql-cancellation" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;, if you want to rerun them.&lt;/p&gt;

&lt;p&gt;I only tested &lt;code&gt;pg&lt;/code&gt;. If you've checked how &lt;code&gt;mysql2&lt;/code&gt;, Prisma or another driver treats an abort signal, tell me what you found in the comments.&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>postgres</category>
      <category>backend</category>
    </item>
    <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>
  </channel>
</rss>
