· 5 min read
Guards have to be synchronous
A busy state greys the button out, but it doesn't stop a second submit. The gate has to flip a plain variable in the same tick as the first call, and the call sites matter more than the component.
A greyed-out button still takes a second click
busy only greys the button out. It doesn't stop anything.
That sounds pedantic until you write it down as a race. The whole purpose of a guard is to make the second caller see what the first caller did. React state updates are asynchronous and batched, so the second caller may read the state before the first caller's write has been applied. Two clicks landing in the same batch both read busy === false, both pass the check, and both go ahead.
So for anything that must happen at most once, the guard has to flip a plain variable in the same tick as the first call. Anything scheduled (state, an effect, a re-render) is presentation.
You need both halves. The variable prevents the second call, and the state tells the person at the keyboard that something's happening.
What made this concrete
The evidence came from my own data. There were two pairs of identical projects in the database, one pair created three seconds apart and another seven seconds apart two days later. Each row in each pair had its own id and its own created audit event, so this wasn't a rendering duplicate or a stale cache. The application really did run the create twice.
Three seconds apart rules out a double-click, which is the explanation the usual advice is written against. What was going on was slower and more ordinary. Saving a project awaits three writes before the form closes (create the record, set its type, set its children), and the form stayed on screen with an enabled Save button the whole time. Saves were unusually slow that week for a reason unrelated to this note. A defaulting bug, fixed the same morning, made every new project arrive with twenty-two children ticked, so every save reparented twenty-two projects.
So you press Save, nothing seems to happen, and you press it again.
I think the duplicate-submit problem is less about double-clicking and more about how long the gap is between "the user acted" and "the interface acknowledged it". Anything that widens that window (a slow network, three sequential writes, an unlucky week) turns a theoretical race into a routine one.
The gate
The guard is a closure over an ordinary variable, and it isn't a hook, on purpose.
export function createSubmitGate(): SubmitGate {
let pending = false
return {
get pending() {
return pending
},
async run(fn) {
if (pending) return false
pending = true
try {
await fn()
} finally {
pending = false
}
return true
},
}
}That's three decisions in fifteen lines.
pending is a plain variable. It's set in the same synchronous tick as the first call, so the second call sees it however fast it arrives. That's the entire point of the module and the only part that can't be done with state.
The release is in a finally. A failed save mustn't wedge the form shut for ever. A refused parent, a bad total or a database that isn't there all have to leave me able to correct the form and press Save again. A guard that only releases on success will eventually eat a legitimate submission, which is much harder to diagnose than the duplicate it was preventing.
run returns a boolean. It's true if it ran and false if it was refused as a duplicate, so a caller can tell "declined" from "done" and a press never just disappears. You can't write a test against a guard that can't report a refusal, and the tests are how I know the race is closed.
In the form component the gate lives in a ref, and busy still exists alongside it, doing its own separate job of greying the button out.
The bug in the call sites
If the story stopped at the component, this would be a note about React batching. The more useful finding was upstream, outside the component entirely.
Every call site was written the same way.
<RecordForm onSubmit={(v) => { void submit(v) }} />That arrow function returns undefined. The form awaits it, gets a resolved value straight away, and concludes that the save has already finished. void is doing what it's documented to do (discard the value), and in doing that it throws away the only signal the form had.
The form was never given the information it needed to protect itself. No amount of care inside the component could have fixed it, because the component was being told, truthfully as far as it could tell, that the work was complete. The problem was in the pattern, the way every form was being called, which is why it was on all six finance forms and not just the one I'd noticed a problem with.
The way I'd written every call site switched the guard off. So the signature now declares that a submit handler may return a promise and that the form will await it, and every call site returns the promise.
onSubmit: (v: Record<string, string>) => void | Promise<void>It's worth noting which form had the worse exposure, and it wasn't projects, where I happened to spot the duplicates. Both payment forms shared the pattern, and a duplicated payment silently inflates my recorded income. The design doc for that phase had already named that, months earlier, as the single most likely way this system could cost me real money. I just found the cheap version of it first.
How to find this in your own code
The quickest way to find it is a grep. Look for void someAsyncThing() at any boundary where the caller is supposed to know when the work is finished. It's a legitimate operator and the right tool for real fire-and-forget, but at a submit boundary it tells the form the save's done when it isn't.
Then ask the two questions this comes down to. Is the thing preventing a second call synchronous with the first? And does the component that owns the guard actually get the information it needs to run it?
If either answer's no, it'll look fine until a save is slow.