Stop duplicate saves in React Native with a retained save gate
An AI-generated diff is easier to assess when you can explain what happens on the first call, a second overlapping call and a rejection. The previous lesson defined those cases. This one implements their smallest useful contract.
Decide whether to start before awaiting
The complete save gate is small enough to inspect directly:
export function createSaveGate() {
let pending = false;
return async function runSave(
task: () => Promise<void>,
): Promise<'saved' | 'ignored'> {
if (pending) return 'ignored';
pending = true;
try {
await task();
return 'saved';
} finally {
pending = false;
}
};
}
The first admitted call sets pending before awaiting the task. A second call through that same returned function sees the retained flag and returns ignored without invoking its callback.
An ignored result means this attempt did not perform a save. It does not certify that the earlier request succeeded. A caller that displays success for every resolved call would misrepresent that result.
Follow failure all the way out
The finally block releases the same pending flag after success, rejection or a synchronous throw from the task. The gate does not catch and replace the error, so the rejected operation still reaches its caller with the original error.
This shorter version has a recovery defect:
pending = true;
await task();
pending = false;
If the task rejects, execution skips the reset. Later calls remain blocked. Merely seeing a reset somewhere in the function is insufficient; follow the path that reaches it.
Retain the gate for the intended owner
A correct helper can still be used incorrectly:
async function handlePress() {
const runSave = createSaveGate();
await runSave(save);
}
Every press creates an independent pending value. Those separate gates cannot reject overlapping calls through each other.
The supplied caller retains the gate with a ref:
const runSave = useRef(createSaveGate()).current;
React's useRef documentation explains that the ref object persists across renders and mutations do not trigger rendering. In this sample, the retained function owns admission; React state owns visible feedback. Creating this small gate has no external side effects. The expression still evaluates the initializer on later renders even though the ref retains its initial value.
A module-global gate would give the flag a different lifetime and make unrelated owners share it. Choose ownership from the task, not from whichever variable location is easiest to write.
Keep request control separate from feedback
Inside the admitted task, the caller sets busy, clears the previous error and runs onSave. It clears busy in finally and catches failure outside the gate to show a localized message.
The existing button receives:
loading={busy}
disabled={busy}
accessibilityState={{ disabled: busy, busy }}
These props explain state to the user. The gate controls whether another task starts. Inspect both; neither role replaces the other.
The TSX file references illustrative AppButton and AppText components. Adapt it to the host project and verify native interaction and accessibility there. This standalone sample does not mount a screen.
Exercise: explain a rejected save
Trace Case B in the review cases. Identify where the reset is skipped, explain why retry stays blocked, and suggest releasing the retained flag in finally. Then inspect where that flag lives: solving release does not automatically solve ownership.
Next: run checks and state what they prove.
The storyboard contains the spoken lesson. The video uses English AI-cloned owner narration with English and Arabic subtitles. This gate does not implement server idempotency, unmount cancellation or cross-device coordination.
Comments