-
-
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; |
I haven’t tried to do anything graceful when the
delay
changes, so the pending timeout just restarts from scratch, which means the total time elapsed between calling and firing could end up being the sum of the old and the new delay in the worst case. If it was really important to be precise then I guess you could record timestamps and recalculate the updated delay to account for time that has already elapsed, but since it’s impossible to go backwards in time in the case whendelay
has got shorter, I don’t think an ideal solution is possible here so I’m accepting a small amount of imperfection in exchange for simplicity.
Now that I think about it, since callCount
is just working as a Lamport clock anyway, it wouldn’t be a big deal to replace it with the actual clock (lastCalledAt
or whatever), which would make it easy to compute the setTimeout
value from the difference between the current time and lastCalledAt + delay
. That still doesn’t let you travel backwards in time but it’s barely any more complexity as long as you don’t mind making the hook depend upon global time. 🤷♂️
To clarify the above, here’s the “actual clock” version with a UI which demonstrates the difference:
import React, { useState, useEffect, useRef } from 'react';
const useDebounce = (callback, delay) => {
const latestCallback = useRef();
const [lastCalledAt, setLastCalledAt] = useState(null);
useEffect(() => {
latestCallback.current = callback;
}, [callback]);
useEffect(() => {
if (lastCalledAt) {
const fire = () => {
setLastCalledAt(null);
latestCallback.current();
};
const fireAt = lastCalledAt + delay;
const id = setTimeout(fire, fireAt - Date.now());
return () => clearTimeout(id);
}
}, [lastCalledAt, delay]);
return () => setLastCalledAt(Date.now());
};
const App = () => {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(3000);
const handleIncrement = () => setCount(count => count + 1);
const handleClick = useDebounce(() => alert(`I've been clicked ${count} times`), delay);
const handleChange = ({ target: { value } }) => setDelay(parseInt(value));
return (
<>
<button onClick={handleClick}>Click</button>
<button onClick={handleIncrement}>Increment {count}</button>
<input type="range" min="0" max="10000" value={delay} onChange={handleChange} />
Debouncing for {delay}ms
</>
);
};
export default App;
With your version, moving the slider after pressing the button does nothing at all because the previous delay has already been baked into the timeout; with my callCount
version, moving the slider resets the delay from scratch, which means that if you keep wiggling the slider forever then the callback will never fire; with this lastCalledAt
version you can wiggle the slider and the callback still fires at the right time.
I don’t know whether the additional complexity is worth this unbelievably fringe benefit, but as I say, it’s not much more complexity. What I find appealing about this approach is that it tidily describes the problem in a declarative way: the state maintained by the hook is “when did the user last call the debounced callback?”, and its job is to use useEffect
to keep that information synchronised with the mutable world around it, taking into account the (possibly varying-over-time) delay
parameter. Satisfying.
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 the setTimeout
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 with setInterval
but I think your changes make sense. 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), e.g.
> (new Date('2019-03-31 02:00:01')) - (new Date('2019-03-31 00:59:59'))
2000
My only concern would be that the delay
could result in a negative delay passed to setTimeout
but we could easily clamp this with Math.max
, e.g.
import React, { useState, useEffect, useRef } from 'react';
const useDebounce = (callback, delay) => {
const latestCallback = useRef();
const [lastCalledAt, setLastCalledAt] = useState(null);
useEffect(() => {
latestCallback.current = callback;
}, [callback]);
useEffect(() => {
if (lastCalledAt) {
const fire = () => {
setLastCalledAt(null);
latestCallback.current();
};
const fireAt = lastCalledAt + delay;
const fireIn = Math.max(fireAt - Date.now(), 0);
const id = setTimeout(fire, fireIn);
return () => clearTimeout(id);
}
}, [lastCalledAt, delay]);
return () => setLastCalledAt(Date.now());
};
const App = () => {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(3000);
const handleIncrement = () => setCount(count => count + 1);
const handleClick = useDebounce(() => alert(`I've been clicked ${count} times`), delay);
const handleChange = ({ target: { value } }) => setDelay(parseInt(value));
return (
<>
<button onClick={handleClick}>Click</button>
<button onClick={handleIncrement}>Increment {count}</button>
<input type="range" min="0" max="10000" value={delay} onChange={handleChange} />
Debouncing for {delay}ms
</>
);
};
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?
This is great!
Although it works perfectly, here are a few imaginary problems in case you’re interested:
You’re not responding to data flow because you ignore any changes to
delay
during a pending timeout. This isn’t a problem in practice becausedelay
will be short in most debouncing applications and any changes will be reflected the next time the debounced callback fires so who cares, but in theory it means the pending timeout is stale as soon as a newdelay
is set.You’re doing imperative work (mutating the
latestTimeout
ref; callingsetTimeout()
/clearTimeout()
) outside of an effect. Again this isn’t a problem in practice because those are all extremely simple operations, but in principle you’re meant to defer non-urgent work until after the component has re-rendered rather than doing it synchronously and potentially blocking the main thread before the browser has had a chance to paint anything.You’re doing your own cleanup by remembering the timeout ID in the
latestTimeout
ref and then clearing it next time the debounced callback fires, but this is exactly the sort of bookkeeping overhead whichuseEffect
cleanup is designed to eliminate. Note thatuseEffect
cleanup happens next time the effect runs, not only when the component unmounts, so that’s the perfect opportunity to clear your timeout. And because an effect and its cleanup are set up at the same time, that presents the further opportunity to remember the timeout ID in the cleanup closure rather than having to explicitly stash it in a ref.Here’s a version which addresses all three of the above fantasy-land issues:
I’ve moved all the imperative work into an effect, which both satisfies the “do it later” goal and creates the opportunity to use the effect cleanup to clear the timeout, thereby removing the need for
latestTimeout
. By declaringdelay
as a dependency of this effect I also get the “responds to data flow” property, for whatever little that’s worth in this case.The only wrinkle is that I had to think of a way for the effect to be triggered by the debounced callback; as you can see, I decided to add some state which counts how many times the debounced callback has been called since we last allowed it to fire and then made that number a dependency of the
setTimeout
effect, which neatly allows the caller to indirectly trigger the effect by bumping the counter. It’s important that we don’t callsetTimeout
if the debounced callback has been called zero times, so I’ve put a conditional inside the effect to make it do nothing in that case.Going back to the “respond to data flow” point: I haven’t tried to do anything graceful when the
delay
changes, so the pending timeout just restarts from scratch, which means the total time elapsed between calling and firing could end up being the sum of the old and the new delay in the worst case. If it was really important to be precise then I guess you could record timestamps and recalculate the updated delay to account for time that has already elapsed, but since it’s impossible to go backwards in time in the case whendelay
has got shorter, I don’t think an ideal solution is possible here so I’m accepting a small amount of imperfection in exchange for simplicity.