JericaWLancaster

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.

This snippet, along with any other code on this page, is released to the public domain under the Creative Commons Zero (CC0) license(opens in new tab).

Last updated on

July 29th, 2021

# of hits