we're all using Promises now
Many implementations I've seen over-complicate the problem or have other hygiene issues. It's 2021 and we've been using Promises for a long time now – and for good reason, too. Promises clean up asynchronous programs and reduce the opportunities for mistakes to happen. In this post we will write our own debounce
. This implementation will -
- have at most one promise pending at any given time (per debounced task)
- stop memory leaks by properly cancelling pending promises
- resolve only the latest promise
- demonstrate proper behaviour with live code demos
We write debounce
with its two parameters, the task
to debounce, and the amount of milliseconds to delay, ms
. We introduce a single local binding for its local state, t
-
function debounce (task, ms) {
let t = { promise: null, cancel: _ => void 0 }
return async (...args) => {
try {
t.cancel()
t = deferred(ms)
await t.promise
await task(...args)
}
catch (_) { /* prevent memory leak */ }
}
}
We depend on a reusable deferred
function, which creates a new promise that resolves in ms
milliseconds. It introduces two local bindings, the promise
itself, an the ability to cancel
it -
function deferred (ms) {
let cancel, promise = new Promise((resolve, reject) => {
cancel = reject
setTimeout(resolve, ms)
})
return { promise, cancel }
}
click counter example
In this first example, we have a button that counts the user's clicks. The event listener is attached using debounce
, so the counter is only incremented after a specified duration -
// debounce, deferred
function debounce (task, ms) { let t = { promise: null, cancel: _ => void 0 }; return async (...args) => { try { t.cancel(); t = deferred(ms); await t.promise; await task(...args); } catch (_) { console.log("cleaning up cancelled promise") } } }
function deferred (ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }
// dom references
const myform = document.forms.myform
const mycounter = myform.mycounter
// event handler
function clickCounter (event) {
mycounter.value = Number(mycounter.value) + 1
}
// debounced listener
myform.myclicker.addEventListener("click", debounce(clickCounter, 1000))
<form id="myform">
<input name="myclicker" type="button" value="click" />
<output name="mycounter">0</output>
</form>
live query example, "autocomplete"
In this second example, we have a form with a text input. Our search
query is attached using debounce
-
// debounce, deferred
function debounce (task, ms) { let t = { promise: null, cancel: _ => void 0 }; return async (...args) => { try { t.cancel(); t = deferred(ms); await t.promise; await task(...args); } catch (_) { console.log("cleaning up cancelled promise") } } }
function deferred (ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }
// dom references
const myform = document.forms.myform
const myresult = myform.myresult
// event handler
function search (event) {
myresult.value = `Searching for: ${event.target.value}`
}
// debounced listener
myform.myquery.addEventListener("keypress", debounce(search, 1000))
<form id="myform">
<input name="myquery" placeholder="Enter a query..." />
<output name="myresult"></output>
</form>
multiple debounces, react hook useDebounce
In another Q&A someone asks if its possible to use expose the debounce cancellation mechanism and create a useDebounce
React hook. Using deferred
above, it's a trivial exercise.
// revised implementation
function debounce(task, ms) {
let t = { promise: null, cancel: _ => void 0 }
return [
// ...,
_ => t.cancel() // ✅ return cancellation mechanism
]
}
// revised usage
const [inc, cancel] = debounce(clickCounter, 1000) // ✅ two controls
myform.mybutton.addEventListener("click", inc)
myform.mycancel.addEventListener("click", cancel)
Implementing a useDebounce
React hook is a breeze -
function useDebounce(task, ms) {
const [f, cancel] = debounce(task, ms)
useEffect(_ => cancel) // ✅ auto-cancel when component unmounts
return [f, cancel]
}
Head over to the original Q&A for a complete demo