DEV Community

Cover image for My web app fired two POST requests per submit. The fix taught me what React StrictMode is actually for.
Andrii Krugliak
Andrii Krugliak

Posted on

My web app fired two POST requests per submit. The fix taught me what React StrictMode is actually for.

We run an app where you describe a task and an AI agent does it. The first step after you hit submit is a planning call: POST /api/web/tasks/plan, which turns your free text into a structured plan the agents can pick up. One submit should mean one plan.

While testing locally I noticed two plan requests going out per submit. Same payload, fired back to back. The agents handled it fine because the second plan just overwrote the first, but it bothered me. A doubled write is a doubled write, and the next one might not be idempotent.

First wrong guess: a double-click

My first assumption was the obvious one. The user double-clicks, or the button is not disabled during the request, so two clicks sneak through. I added the disabled state, watched the network tab, and got two requests from a single click. So it was not the button.

The thing I had stopped seeing

The submit logic lived in an effect. When the form phase flipped to submitting, the effect ran and fired the plan call. There was a second effect too: when the user changed the tier or output format mid-flow, a matching effect re-planned, because a different tier means a different plan.

Neither effect had any guard against running twice. And in development, React StrictMode mounts every component, unmounts it, and mounts it again, on purpose, to surface effects that are not safe to re-run. My plan effect was exactly the kind of effect StrictMode is built to expose. The double mount fired it twice.

The detail that made it click: I built the app for production and watched the network tab there. Exactly one request. The double was a development-only artifact of StrictMode doing its job. The bug was never in production traffic, but the fact that StrictMode could double it meant my effect was not safe, and an unsafe effect is a latent bug waiting for a real remount.

The fix: ref guards set before the await, not reset in cleanup

The instinct is to reach for a boolean. The catch is where you reset it. If you reset the guard in the effect cleanup, StrictMode runs the cleanup between its two mounts, so the guard is clear again by the time the second mount fires, and you are back to two requests. The cleanup reset is the trap.

So I set the guard before the async call and did not reset it in cleanup:

const plannedSubmitRef = useRef(false)

useEffect(() => {
  if (phase !== 'submitting') return
  if (plannedSubmitRef.current) return
  plannedSubmitRef.current = true        // set BEFORE the await
  void planTask(payload)                  // a StrictMode remount cannot re-fire
}, [phase, payload])
Enter fullscreen mode Exit fullscreen mode

The re-plan effect needed to stay alive for a real tier or format change, so a plain boolean was too blunt. I keyed its guard on a signature of the things that should trigger a re-plan:

const matchPlanRef = useRef<string | null>(null)

useEffect(() => {
  const signature = `${tier}|${format}`
  if (matchPlanRef.current === signature) return
  matchPlanRef.current = signature        // same tier+format = no re-plan
  void rematchPlan(tier, format)
}, [tier, format])
Enter fullscreen mode Exit fullscreen mode

A StrictMode double mount produces the same signature both times, so the second run is a no-op. A genuine tier or format change produces a new signature and re-plans, which is what we want. Both guards get reset by a separate phase-watcher effect when the flow actually starts over, not by cleanup.

What I actually took from it

I used to treat StrictMode as a noisy dev setting that double-logs and clutters the console. It is not noise. It is a free fuzzer for effect safety. The double invoke is the test, and my effect failed it. Making development match production was not the goal; making the effect safe to re-run was, and that also closed the real edge where a legitimate remount would have doubled a write.

If you see something fire twice only in development, do not silence StrictMode. Fix the effect so it does not care how many times it mounts. Guard before the await, key the guard on what should actually re-trigger, and reset it on the real lifecycle event rather than in cleanup.

We build BotWork, an AI agent freelance network where you describe a task, an agent does it, and you only pay if the result is good. This was one small reliability bug behind the submit button, but the StrictMode lesson stuck with me longer than the fix did.

Top comments (0)