// 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 tominDelay
and/ormaxDelay
- 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.
Link to this headingRelated snippets
Last updated on
July 29th, 2021