-
-
Save mudge/eb9178a4b6d595ffde8f9cb31744afcf to your computer and use it in GitHub Desktop.
/* | |
* Inspired by Dan Abramov's "Making setInterval Declarative with React Hooks", | |
* this is a custom hook for debouncing a callback (e.g. for click handlers) such | |
* that a callback will not be fired until some delay has passed since the last click. | |
* The callback will automatically be updated with the latest props and state on every | |
* render meaning that users don't need to worry about stale information being used. | |
* | |
* See https://overreacted.io/making-setinterval-declarative-with-react-hooks/ for the | |
* original inspiration. | |
*/ | |
import React, { useState, useEffect, useRef } from 'react'; | |
const useDebounce = (callback, delay) => { | |
const latestCallback = useRef(); | |
const latestTimeout = useRef(); | |
useEffect(() => { | |
latestCallback.current = callback; | |
}, [callback]); | |
return () => { | |
if (latestTimeout.current) { | |
clearTimeout(latestTimeout.current); | |
} | |
latestTimeout.current = setTimeout(() => { latestCallback.current(); }, delay); | |
}; | |
}; | |
const App = () => { | |
const [count, setCount] = useState(0); | |
const handleIncrement = () => setCount(count => count + 1); | |
const handleClick = useDebounce(() => alert(`I've been clicked ${count} times`), 3000); | |
return ( | |
<> | |
<button onClick={handleClick}>Click</button> | |
<button onClick={handleIncrement}>Increment {count}</button> | |
</> | |
); | |
} | |
export default App; |
Thanks for the detailed feedback, @tomstuart; this is all gold!
You’re very welcome! It was a lot of fun to think about.
I was initially reticent about using real
Date
s but it seems that JavaScript should do the right thing with edge cases like Daylight Saving Time (but I'm not certain about leap seconds)
Fear not: Date.now()
returns a number rather than a Date
instance, and the spec explicitly says “leap seconds are ignored”, so stuff like daylight savings and leap seconds [0] shouldn’t be a problem (although I guess that depends on what exactly “ignored” means). Of course we’re exposed to all the usual issues that can occur if the system clock is set backwards or forwards in the middle of our code, but I think that’s the price we pay for involving global time.
My only concern would be that the
delay
could result in a negative delay passed tosetTimeout
but we could easily clamp this withMath.max
Fear not: setTimeout()
’s delay
argument is already clamped to zero (see timer initialisation step 10) so we don’t need to do this ourselves.
[0] Having thought about this, I suspect “leap seconds are ignored” probably means that it is a problem. If that were important then I guess we could use performance.now()
as our clock and fall back to Date.now()
only when it’s unsupported, or of course switch back to the callCount
Lamport clock and avoid global time altogether. Pragmatically it seems likely that leap seconds aren’t significant enough to worry about.
Phew, thanks for putting my mind at ease on both points.
How do I stop the propagation when the handler is an onSubmit function on form?
Thanks for the detailed feedback, @tomstuart; this is all gold!
The stashing of the timeout ID in a ref and not using the cleanup functionality
useEffect
were both things that bothered me and it's now clear from your comments the root issue was trying to do the imperative work when the user clicks rather than deferring the work until next render. This is something I felt uneasy about (I was torn about whether the click handler should follow the React programming model or whether the user can expect thesetTimeout
to fire immediately when they click) but the fact my original solution requires both the extra ref and the manual timeout cleanup is clearly a smell.Responding to changes in
delay
is something I conveniently skipped from Dan Abramov's original example withsetInterval
but I think your changes make sense. I was initially reticent about using realDate
s but it seems that JavaScript should do the right thing with edge cases like Daylight Saving Time (but I'm not certain about leap seconds), e.g.My only concern would be that the
delay
could result in a negative delay passed tosetTimeout
but we could easily clamp this withMath.max
, e.g.