JoshWComeau

useRandomInterval

Filed under
Snippets
on
in
July 29th, 2021.
Jul 2021.
// Utility helper for random number generation
const random = (min, max) =>
  Math.floor(Math.random() * (max - min)) + min;

const useRandomInterval = (callback, minDelay, maxDelay) => {
  const timeoutId = React.useRef(null);
  const savedCallback = React.useRef(callback);

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

  React.useEffect(() => {
    let isEnabled =
      typeof minDelay === 'number' && typeof maxDelay === 'number';

    if (isEnabled) {
      const handleTick = () => {
        const nextTickAt = random(minDelay, maxDelay);

        timeoutId.current = window.setTimeout(() => {
          savedCallback.current();
          handleTick();
        }, nextTickAt);
      };

      handleTick();
    }

    return () => window.clearTimeout(timeoutId.current);
  }, [minDelay, maxDelay]);

  const cancel = React.useCallback(function () {
    window.clearTimeout(timeoutId.current);
  }, []);

  return cancel;
};

Link to this headingContext

In JavaScript, we have two primitives for scheduling something in the future based on an amount of time:

  • setTimeout
  • setInterval

setTimeout is great for one-off events, and setInterval is great for things that happen on a fixed schedule… but what if we want something to happen a bit more spontaneously?

For example, consider this . The goal is to schedule new sparkles to be produced in an ongoing fashion, but not uniformly; Each sparkle appears between 20ms and 500ms after the last one. This variation makes it feel more organic / less robotic.

This hook is great for animations and microinteractions. If you're generating particles for a confetti or firework effect, having a random delay between each particle can add a lot of life to the effect.

Link to this headingDemo

Here the hook is used to change the "heartbeat" of a pulsing circle. The slider controls the min and max time values. Notice how the effect changes depending on their position:

Link to this headingUsage

This example uses the hook to create a "laggy" clock (a clock that only updates once every few seconds):

function LaggyClock() {
  // Update between every 1 and 4 seconds
  const delay = [1000, 4000];

  const [currentTime, setCurrentTime] = React.useState(Date.now);

  useRandomInterval(() => setCurrentTime(Date.now()), ...delay);

  return <>It is currently {new Date(currentTime).toString()}.</>;
}

Link to this headingExplanation

This hook is not simple, and it's because we have to be pretty crafty about how we make sure a relevant callback is made available to the hook; this problem and solution is explored in depth in Dan Abramov's blog post(opens in new tab) on setInterval. If you haven't already read it, I would recommend starting there.

In order to create a "random" interval, we need to use setTimeout. On every "tick", we schedule the next iteration a random amount of time in the future, based on the min and max values provided.

At its core, here's what this trick looks like:

function tick() {
  doSomething();

  window.setTimeout(tick, Math.random() * 5000);
}

tick();

A function has some sort of effect (doSomething), but it also calls itself recursively, after a random amount of time. This continues indefinitely, with each loop being between 0 and 5 seconds after the previous one.

There are two ways to "cancel" this random interval:

  • Pass a null value to minDelay and/or maxDelay
  • Call the returned cancel function

The first method is the preferred one; by setting a null delay length, the loop will stop getting called. This is because our effect has some cleanup; whenever the delays change, it interrupts the current timeout:

React.useEffect(() => {
  if (typeof minDelay === 'number' && typeof maxDelay === 'number') {
    // ...snip
  }

  // Called whenever the delays change:
  return () => window.clearTimeout(timeoutId.current);
}, [minDelay, maxDelay]);

If minDelay or maxDelay is null, the cleanup will run to clear the timeout, but no new timeout will be set.

Finally, there is the cancel function:

const cancel = React.useCallback(function () {
  window.clearTimeout(timeoutId.current);
}, []);

return cancel;

This provides an imperative way to interrupt the loop without triggering a re-render. It is an escape hatch and shouldn't be used in most cases.

It's wrapped in useCallback so that it can safely be passed to child elements without busting a React.memo component. While I might consider this a premature optimization in a typical case, I think it's a fair optimization for generalized, reusable components like this one.

Last updated on

July 29th, 2021

# of hits