useLockFn wraps an async function so it cannot overlap with itself: while one call is still in flight, any further call is ignored until the first one settles. It is the standard fix for the double-submit bug — a Save, Like, or Pay button that fires its handler twice because the user clicked (or double-clicked) faster than the request came back. This is the ahooks hook of the same name.
You are given an async function. Return a wrapped version that runs at most one call at a time and drops the rest.
function useLockFn<Args extends any[], R>(
asyncFn: (...args: Args) => Promise<R>
): (...args: Args) => Promise<R | undefined>;
The wrapped function takes the same arguments as asyncFn. It resolves to asyncFn's value for a call that actually runs, and to undefined for a call that was ignored.
A single call runs the function and forwards its result:
const save = useLockFn(saveDraft);
await save({ title: 'Hi' }); // runs saveDraft, resolves to its value
A second call fired while the first is still pending is dropped — saveDraft runs once, not twice:
const save = useLockFn(saveDraft); // saveDraft takes 300ms
save(); // call 1: runs saveDraft
save(); // call 2: still pending → ignored, resolves to undefined
// ...after call 1 settles...
save(); // call 3: lock released → runs saveDraft again
asyncFn and resolves to undefined. It is dropped, not deferred and replayed later.We wrap an async function in a one-at-a-time gate: the first call runs, and every call that arrives before it settles is dropped.
A user clicks Save. The request takes a moment, so before it comes back they click again — or their trackpad fires a double-click. Now saveDraft runs twice, and you get two draft rows, two charges, two of whatever the handler does. You want the second click to do nothing while the first request is still open, then work normally once it has finished. That is a lock: held while a call is in flight, released the instant it settles.
The whole hook is one boolean: is a call running right now? The subtlety is where you keep it. The two clicks of a double-click happen in the same tick — both event handlers run before React re-renders even once. So the flag the second call reads has to already reflect the write the first call made, in the same synchronous moment. A value stored in useState cannot do that: setState schedules an update, it does not change the current render's variable. A useRef can — writing ref.current is visible to the very next line.
The obvious version reaches for useState — that is how you track "is something happening" everywhere else:
const { useState } = require('react');
function useLockFnNaive(asyncFn) {
const [running, setRunning] = useState(false);
return async (...args) => {
if (running) return; // `running` is THIS render's value
setRunning(true); // async — does not update `running` now
try {
return await asyncFn(...args);
} finally {
setRunning(false);
}
};
}
It survives a lazy test — click, wait for the result, click again — because by then a re-render has flushed running back and forth. But it fails the exact case it exists for. Two clicks in one tick both close over the same render, where running is still false. setRunning(true) from the first click does not change that local running, so the second click reads false, passes the guard, and calls asyncFn a second time. The double-submit you were trying to prevent sails straight through.
const { useRef, useCallback } = require('react');
function useLockFn(asyncFn) {
// A ref, not state: the second call of a double-click runs in the same tick,
// before any re-render, so the flag must update synchronously and be read
// back on the very next call. `ref.current` does that; a state value cannot.
const lockRef = useRef(false);
return useCallback(
async (...args) => {
if (lockRef.current) return; // already running → drop it (resolves to undefined)
lockRef.current = true; // take the lock synchronously
try {
return await asyncFn(...args); // run it; forward the resolved value
} finally {
lockRef.current = false; // release on BOTH resolve and reject
}
},
[asyncFn], // stable identity while asyncFn is stable
);
}
module.exports = { useLockFn };
Two changes carry it. The flag moves from useState to useRef, so setting it is synchronous and the second same-tick call sees true and returns early. And the release lives in finally, not in a .then — so a call that rejects still opens the lock on its way out, and because there is no catch, that rejection still reaches the caller. Wrapping the whole thing in useCallback keyed on asyncFn gives the returned function a stable identity, so it can be a dependency or a prop to a memoized child without churning.
Take a Save button whose saveDraft takes 300ms, double-clicked at the start:
lockRef.current is false, so we set it true and call saveDraft(draft). await suspends and the wrapped call returns a pending promise.lockRef.current is already true, so we return immediately — resolving to undefined, never touching saveDraft. The second click did nothing, exactly as intended.finally runs and sets lockRef.current back to false. On resolve the caller gets the value; on reject the error propagates.saveDraft again."Two calls arrive at once" has three reasonable answers, and it is worth knowing which one this is. useLockFn drops the extra calls — the first wins, the rest resolve to undefined. A singleflight (dedupe) wrapper shares one in-flight promise, handing every concurrent caller the same eventual result. A semaphore or mutex queues the calls and runs them one after another. Same starting picture, three different contracts; pick by whether the dropped call needs to happen at all.
useState — setState is async, so two same-tick calls both read the stale false and both fire. Keep the guard in a useRef you read and write synchronously..then, a rejected call leaves it stuck true and the button locks forever. Release in finally, which runs on resolve and reject.catch that eats the rejection to "be safe" hides failures from the caller. Let it propagate; finally still releases the lock either way.useCallback gives it a new identity each render, breaking memoized children and effect dependencies. Memoize on asyncFn.undefined, so they all await one request (the singleflight pattern) rather than getting nothing back.useState boolean used only to render a spinner or disable the button; keep the guard itself in the ref so the same-tick case still works.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useLockFn wraps an async function so it cannot overlap with itself: while one call is still in flight, any further call is ignored until the first one settles. It is the standard fix for the double-submit bug — a Save, Like, or Pay button that fires its handler twice because the user clicked (or double-clicked) faster than the request came back. This is the ahooks hook of the same name.
You are given an async function. Return a wrapped version that runs at most one call at a time and drops the rest.
function useLockFn<Args extends any[], R>(
asyncFn: (...args: Args) => Promise<R>
): (...args: Args) => Promise<R | undefined>;
The wrapped function takes the same arguments as asyncFn. It resolves to asyncFn's value for a call that actually runs, and to undefined for a call that was ignored.
A single call runs the function and forwards its result:
const save = useLockFn(saveDraft);
await save({ title: 'Hi' }); // runs saveDraft, resolves to its value
A second call fired while the first is still pending is dropped — saveDraft runs once, not twice:
const save = useLockFn(saveDraft); // saveDraft takes 300ms
save(); // call 1: runs saveDraft
save(); // call 2: still pending → ignored, resolves to undefined
// ...after call 1 settles...
save(); // call 3: lock released → runs saveDraft again
asyncFn and resolves to undefined. It is dropped, not deferred and replayed later.We wrap an async function in a one-at-a-time gate: the first call runs, and every call that arrives before it settles is dropped.
A user clicks Save. The request takes a moment, so before it comes back they click again — or their trackpad fires a double-click. Now saveDraft runs twice, and you get two draft rows, two charges, two of whatever the handler does. You want the second click to do nothing while the first request is still open, then work normally once it has finished. That is a lock: held while a call is in flight, released the instant it settles.
The whole hook is one boolean: is a call running right now? The subtlety is where you keep it. The two clicks of a double-click happen in the same tick — both event handlers run before React re-renders even once. So the flag the second call reads has to already reflect the write the first call made, in the same synchronous moment. A value stored in useState cannot do that: setState schedules an update, it does not change the current render's variable. A useRef can — writing ref.current is visible to the very next line.
The obvious version reaches for useState — that is how you track "is something happening" everywhere else:
const { useState } = require('react');
function useLockFnNaive(asyncFn) {
const [running, setRunning] = useState(false);
return async (...args) => {
if (running) return; // `running` is THIS render's value
setRunning(true); // async — does not update `running` now
try {
return await asyncFn(...args);
} finally {
setRunning(false);
}
};
}
It survives a lazy test — click, wait for the result, click again — because by then a re-render has flushed running back and forth. But it fails the exact case it exists for. Two clicks in one tick both close over the same render, where running is still false. setRunning(true) from the first click does not change that local running, so the second click reads false, passes the guard, and calls asyncFn a second time. The double-submit you were trying to prevent sails straight through.
const { useRef, useCallback } = require('react');
function useLockFn(asyncFn) {
// A ref, not state: the second call of a double-click runs in the same tick,
// before any re-render, so the flag must update synchronously and be read
// back on the very next call. `ref.current` does that; a state value cannot.
const lockRef = useRef(false);
return useCallback(
async (...args) => {
if (lockRef.current) return; // already running → drop it (resolves to undefined)
lockRef.current = true; // take the lock synchronously
try {
return await asyncFn(...args); // run it; forward the resolved value
} finally {
lockRef.current = false; // release on BOTH resolve and reject
}
},
[asyncFn], // stable identity while asyncFn is stable
);
}
module.exports = { useLockFn };
Two changes carry it. The flag moves from useState to useRef, so setting it is synchronous and the second same-tick call sees true and returns early. And the release lives in finally, not in a .then — so a call that rejects still opens the lock on its way out, and because there is no catch, that rejection still reaches the caller. Wrapping the whole thing in useCallback keyed on asyncFn gives the returned function a stable identity, so it can be a dependency or a prop to a memoized child without churning.
Take a Save button whose saveDraft takes 300ms, double-clicked at the start:
lockRef.current is false, so we set it true and call saveDraft(draft). await suspends and the wrapped call returns a pending promise.lockRef.current is already true, so we return immediately — resolving to undefined, never touching saveDraft. The second click did nothing, exactly as intended.finally runs and sets lockRef.current back to false. On resolve the caller gets the value; on reject the error propagates.saveDraft again."Two calls arrive at once" has three reasonable answers, and it is worth knowing which one this is. useLockFn drops the extra calls — the first wins, the rest resolve to undefined. A singleflight (dedupe) wrapper shares one in-flight promise, handing every concurrent caller the same eventual result. A semaphore or mutex queues the calls and runs them one after another. Same starting picture, three different contracts; pick by whether the dropped call needs to happen at all.
useState — setState is async, so two same-tick calls both read the stale false and both fire. Keep the guard in a useRef you read and write synchronously..then, a rejected call leaves it stuck true and the button locks forever. Release in finally, which runs on resolve and reject.catch that eats the rejection to "be safe" hides failures from the caller. Let it propagate; finally still releases the lock either way.useCallback gives it a new identity each render, breaking memoized children and effect dependencies. Memoize on asyncFn.undefined, so they all await one request (the singleflight pattern) rather than getting nothing back.useState boolean used only to render a spinner or disable the button; keep the guard itself in the ref so the same-tick case still works.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.