Reputation: 41300
I am pretty sure the answer is that it is not possible, but I was wondering if it is possible to implement lodash.debounce
using Ramda so I can get rid of the lodash
dependency in my app since it's down to just that.
This is the code I am using
import debounce from "lodash.debounce";
import { Dispatch, useCallback, useState } from "react";
/**
* This is a variant of set state that debounces rapid changes to a state.
* This perform a shallow state check, use {@link useDebouncedDeepState}
* for a deep comparison. Internally this uses
* [lodash debounce](https://lodash.com/docs/#debounce) to perform
* the debounce operation.
* @param initialValue initial value
* @param wait debounce wait
* @param debounceSettings debounce settings.
* @returns state and setter
*
*/
export function useDebouncedState<S>(
initialValue: S,
wait: number,
debounceSettings?: Parameters<typeof debounce>[2]
): [S, Dispatch<S>] {
const [state, setState] = useState<S>(initialValue);
const debouncedSetState = useCallback(
debounce(setState, wait, debounceSettings),
[wait, debounceSettings]
);
useEffect(()=> {
return () => debouncedSetState.cancel();
}, []);
return [state, debouncedSetState];
}
Upvotes: 2
Views: 3807
Reputation: 135227
debounce without cancellation
VLAZ linked Can someone explain the "debounce" function in Javascript? but you seem disappointed and looking for something with a cancellation mechanism. The answer I provided to that question implements a vanilla debounce
that -
✅ | 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 |
❌ | Expose cancellation mechanism |
We wrote debounce
with two parameters, the task
to debounce, and the amount of milliseconds to delay, ms
. We introduced a single local binding for its local state, t
-
// original implementation
function debounce(task, ms) {
let t = { promise: null, cancel: _ => void 0 }
return async (...args) => { // ⚠️ does not return cancel mechanism
try {
t.cancel()
t = deferred(ms)
await t.promise
await task(...args)
}
catch (_) { /* prevent memory leak */ }
}
}
// original usage
// ⚠️ how to cancel?
myform.mybutton.addEventListener("click", debounce(clickCounter, 1000))
now with external cancellation
The original code is approachable in size, less than 10 lines, and is intended for you to tinker with to meet your specific needs. We can expose the cancellation mechanism by simply including it with the other returned value -
// revised implementation
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 */ }
},
_ => 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)
deferred
debounce
depends on a reusable deferred
function, which creates a new promise that resolves in ms
milliseconds. Read more about it in the linked Q&A -
function deferred(ms) {
let cancel, promise = new Promise((resolve, reject) => {
cancel = reject
setTimeout(resolve, ms)
})
return { promise, cancel }
}
demo with cancellation
Run the snippet below. The Click is debounced for one (1) second. After the debounce timer expires, the counter is incremented. However, if you click Cancel while inc
is debounced, the pending function will be cancelled and the counter will not be incremented.
// debounce, compressed for demo
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 */ } }, _ => t.cancel() ]
}
// deferred, compressed for demo
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
[inc, cancel] = debounce(clickCounter, 1000)
myform.myclicker.addEventListener("click", inc)
myform.mycancel.addEventListener("click", cancel)
<form id="myform">
<input name="myclicker" type="button" value="click" />
<input name="mycancel" type="button" value="cancel" />
<output name="mycounter">0</output>
</form>
types
Some sensible annotations for deferred
and debounce
, for the people thinking about types.
// cancel : () -> void
//
// waiting : {
// promise: void promise,
// cancel: cancel
// }
//
// deferred : int -> waiting
function deferred(ms) {
let cancel, promise = new Promise((resolve, reject) => {
cancel = reject
setTimeout(resolve, ms)
})
return { promise, cancel }
}
// 'a task : (...any -> 'a)
//
// debounce : ('a task, int) -> ('a task, cancel)
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 */ }
},
_ => t.cancel()
]
}
react hook
Implementing useDebounce
with debounce
is super easy. Remember to cancel
when the component is unmounted to prevent any dangling debounced operations -
function useDebounce(task, ms) {
const [f, cancel] = debounce(task, ms)
useEffect(_ => cancel) // ✅ auto-cancel when component unmounts
return [f, cancel]
}
Add useDebounce
to your component is the same way we used vanilla debounce
above. If debouncing state mutations, make sure to use functional updates as setter will be called asynchronously -
function App() {
const [count, setCount] = React.useState(0)
const [inc, cancel] = useDebounce(
_ => setCount(x => x + 1), // ✅ functional update
1000
)
return <div>
<button onClick={inc}>click</button>
<button onClick={cancel}>cancel</button>
<span>{count}</span>
</div>
}
react debounce demo
This demo is the same as the only above, only use React and our useDebounce
hook -
// debounce, compressed for demo
function debounce(task, ms) {
let t = { promise: null, cancel: _ => void 0 }
return [ (...args) => { t.cancel(); t = deferred(ms); t.promise.then(_ => task(...args)).catch(_ => {}) }, _ => t.cancel() ]
}
// deferred, compressed for demo
function deferred(ms) {
let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel }
}
function useDebounce(task, ms) {
const [f, cancel] = debounce(task, ms)
React.useEffect(_ => cancel)
return [f, cancel]
}
function App() {
const [count, setCount] = React.useState(0)
const [inc, cancel] = useDebounce(
_ => setCount(x => x + 1),
1000
)
return <div>
<button onClick={inc}>click</button>
<button onClick={cancel}>cancel</button>
<span>{count}</span>
</div>
}
ReactDOM.render(<App/>, document.querySelector("#app"))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>
<div id="app"></div>
multiple debounces
Let's double-check everything is correct and show multiple debounces being used on the same page. We'll extend the counter example by adding more Click buttons that call the same debounced function. And we'll put multiple counters on the same page to show that multiple debouncers maintain individual control and don't interrupt other debouncers. Here's a preview of the app -
Run the demo and verify each of these behaviours -
✅ | 3 Counters, each with their own counter state |
✅ | Each counter has 3 debounced Click buttons and a single Cancel button |
✅ | Each Click can be used to increment the counter's value |
✅ | Each Click will interrupt any debounced increment from other Click belonging to that counter |
✅ | The Cancel button will cancel debounced increments from any Click belonging to that counter |
✅ | Cancel will not cancel debounced increments belonging to other counters |
function debounce(task, ms) { let t = { promise: null, cancel: _ => void 0 }; return [ (...args) => { t.cancel(); t = deferred(ms); t.promise.then(_ => task(...args)).catch(_ => {}) }, _ => t.cancel() ] }
function deferred(ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }
function useDebounce(task, ms) {const [f, cancel] = debounce(task, ms); React.useEffect(_ => cancel); return [f, cancel] }
function useCounter() {
const [count, setCount] = React.useState(0)
const [inc, cancel] = useDebounce(
_ => setCount(x => x + 1),
1000
)
return [count, <div className="counter">
<button onClick={inc}>click</button>
<button onClick={inc}>click</button>
<button onClick={inc}>click</button>
<button onClick={cancel}>cancel</button>
<span>{count}</span>
</div>]
}
function App() {
const [a, counterA] = useCounter()
const [b, counterB] = useCounter()
const [c, counterC] = useCounter()
return <div>
{counterA}
{counterB}
{counterC}
<pre>Total: {a+b+c}</pre>
</div>
}
ReactDOM.render(<App/>, document.querySelector("#app"))
.counter { padding: 0.5rem; margin-top: 0.5rem; background-color: #ccf; }
pre { padding: 0.5rem; background-color: #ffc; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>
<div id="app"></div>
Upvotes: 11