Debouncing with promises
I recently discovered that "debouncing" gets unexpectedly tricky when promises are involved.
Consider, if you will, a user input field. As the user types, it queries the server to check for uniqueness of the value, and then displays whether or not the value is unique. Here is the happy path:
A keypress triggers a server fetch. When the server fetch resolves, the UI is updated.
However, the user will press many keys and we can't have our server flooded with unnecessary requests. So, of course, we need to debounce these events!
But here you might notice that we haven't fully thought through the debouncing of this asynchronous server request. Consider the next scenario, where server fetches overlap.
The server has responded to requests in a different order, and the UI is left showing results according to stale user input. This is bad!
There is a surprisingly popular npm module called debounce-promise
with
more than 150,000 weekly downloads as of Feb 2021. But it doesn't quite meet
the needs of this problem. We want to update the UI according to the last
initiated promise, ignoring all others. In contrast, debounce-promise
resolves all accumulated promises to a shared result.
Only one promise should truly resolve. Other "debounced" promises should be
skipped somehow, such as by resolving to a special value of "skipped"
the
instant we know we want to ignore it.
Well, I came up with my own solution. Its promise-resolving behavior is depicted here.
And it is implemented as follows.
import debounce from 'lodash/debounce'; /* any debounce implementation will do */
export function debouncePromise<A, B>(func: (a: A) => Promise<B>, debounceDelay?: number): (a: A) => Promise<B | 'skipped'> {
const promiseResolverRef: { current: (b: B | 'skipped') => void } = {
current: () => {},
};
const debouncedFunc = debounce((a: A) => {
const promiseResolverSnapshot = promiseResolverRef.current;
func(a).then((b) => {
if (promiseResolverSnapshot === promiseResolverRef.current) {
promiseResolverRef.current(b);
}
});
}, debounceDelay);
return (a: A) => new Promise<B | 'skipped'>((resolve) => {
promiseResolverRef.current('skipped');
promiseResolverRef.current = resolve;
debouncedFunc(a);
});
}
And here's an example usage in React.
const debouncedServerFetch = debouncePromise(serverFetch);
function Example() {
const [serverResult, setServerResult] = useState();
return (
<input
onChange={async (e) => {
const result = await debouncedServerFetch(e.target.value);
if (result === 'skipped') {
return;
}
setServerResult(result);
}}
/>
);
}
The end.
- Next: Reading CLI flags in bash
- Previous: Do not eat exceptions