Skip to content

Instantly share code, notes, and snippets.

@mudge
Last active September 22, 2022 10:14
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mudge/eb9178a4b6d595ffde8f9cb31744afcf to your computer and use it in GitHub Desktop.
Save mudge/eb9178a4b6d595ffde8f9cb31744afcf to your computer and use it in GitHub Desktop.
A custom React Hook for a debounced click handler with a given callback and delay.
/*
* 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;
@tomstuart
Copy link

tomstuart commented Aug 24, 2019

This is great!

Although it works perfectly, here are a few imaginary problems in case you’re interested:

  1. 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 because delay 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 new delay is set.

  2. You’re doing imperative work (mutating the latestTimeout ref; calling setTimeout()/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.

  3. 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 which useEffect cleanup is designed to eliminate. Note that useEffect 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:

const useDebounce = (callback, delay) => {
  const latestCallback = useRef();
  const [callCount, setCallCount] = useState(0);

  useEffect(() => {
    latestCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (callCount > 0) {
      const fire = () => {
        setCallCount(0);
        latestCallback.current();
      };

      const id = setTimeout(fire, delay);
      return () => clearTimeout(id);
    }
  }, [callCount, delay]);

  return () => setCallCount(callCount => callCount + 1);
};

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 declaring delay 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 call setTimeout 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 when delay 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.

@tomstuart
Copy link

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 when delay 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. 🤷‍♂️

@tomstuart
Copy link

tomstuart commented Aug 25, 2019

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.

@mudge
Copy link
Author

mudge commented Aug 26, 2019

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 Dates 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;

@tomstuart
Copy link

tomstuart commented Aug 26, 2019

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 Dates 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 to setTimeout but we could easily clamp this with Math.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.

@mudge
Copy link
Author

mudge commented Aug 26, 2019

Phew, thanks for putting my mind at ease on both points.

@vivekmittal
Copy link

How do I stop the propagation when the handler is an onSubmit function on form?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment