Skip to content

Instantly share code, notes, and snippets.

@Danziger
Last active November 15, 2023 18:00
Show Gist options
  • Star 38 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • Save Danziger/336e75b6675223ad805a88c2dfdcfd4a to your computer and use it in GitHub Desktop.
Save Danziger/336e75b6675223ad805a88c2dfdcfd4a to your computer and use it in GitHub Desktop.
✨ Declarative useTimeout (setTimeout), useInterval (setInterval) and useThrottledCallback (useCallback combined with setTimeout) hooks for React (in Typescript)
import React, { useEffect, useRef } from 'react';
/**
* Use setInterval with Hooks in a declarative way.
*
* @see https://stackoverflow.com/a/59274004/3723993
* @see https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
export function useInterval(
callback: React.EffectCallback,
delay: number | null,
): React.MutableRefObject<number | null> {
const intervalRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
// Remember the latest callback:
//
// Without this, if you change the callback, when setInterval ticks again, it
// will still call your old callback.
//
// If you add `callback` to useEffect's deps, it will work fine but the
// interval will be reset.
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Set up the interval:
useEffect(() => {
if (typeof delay === 'number') {
intervalRef.current = window.setInterval(() => callbackRef.current(), delay);
// Clear interval if the components is unmounted or the delay changes:
return () => window.clearInterval(intervalRef.current || 0);
}
}, [delay]);
// In case you want to manually clear the interval from the consuming component...:
return intervalRef;
}
import React, { useEffect, useRef } from 'react';
/**
* Use requestAnimationFrame with Hooks in a declarative way.
*
* @see https://stackoverflow.com/a/59274004/3723993
* @see https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
export function useRAF(
callback: React.EffectCallback,
isRunning: boolean,
): React.MutableRefObject<number | null> {
const rafRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
// Remember the latest callback:
//
// Without this, if you change the callback, when setInterval ticks again, it
// will still call your old callback.
//
// If you add `callback` to useEffect's deps, it will work fine but the
// interval will be reset.
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
function tick() {
rafRef.current = window.requestAnimationFrame(() => {
callbackRef.current();
tick();
});
}
if (isRunning) {
tick();
// Clear RAF if the components is unmounted or the delay changes:
return () => {
window.cancelAnimationFrame(rafRef.current || 0);
};
}
}, [isRunning]);
// In case you want to manually clear the RAF from the consuming component...:
return rafRef;
}
import { useCallback, useEffect, useRef } from 'react';
export function useThrottledCallback<A extends any[]>(
callback: (...args: A) => void,
delay: number,
deps?: readonly any[],
): (...args: A) => void {
const timeoutRef = useRef<number>();
const callbackRef = useRef(callback);
const lastCalledRef = useRef(0);
// Remember the latest callback:
//
// Without this, if you change the callback, when setTimeout kicks in, it
// will still call your old callback.
//
// If you add `callback` to useCallback's deps, it will also update, but it
// might be called twice if the timeout had already been set.
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Clear timeout if the components is unmounted or the delay changes:
useEffect(() => window.clearTimeout(timeoutRef.current), [delay]);
return useCallback((...args: A) => {
// Clear previous timer:
window.clearTimeout(timeoutRef.current);
function invoke() {
callbackRef.current(...args);
lastCalledRef.current = Date.now();
}
// Calculate elapsed time:
const elapsed = Date.now() - lastCalledRef.current;
if (elapsed >= delay) {
// If already waited enough, call callback:
invoke();
} else {
// Otherwise, we need to wait a bit more:
timeoutRef.current = window.setTimeout(invoke, delay - elapsed);
}
}, deps);
}
import React, { useEffect, useRef } from 'react';
/**
* Use requestAnimationFrame + setInterval with Hooks in a declarative way.
*
* @see https://stackoverflow.com/a/59274004/3723993
* @see https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
export function useThrottledRAF(
callback: React.EffectCallback,
delay: number | null,
): [React.MutableRefObject<number | null>, React.MutableRefObject<number | null>] {
const intervalRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
// Remember the latest callback:
//
// Without this, if you change the callback, when setInterval ticks again, it
// will still call your old callback.
//
// If you add `callback` to useEffect's deps, it will work fine but the
// interval will be reset.
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Set up the interval:
useEffect(() => {
if (typeof delay === 'number') {
intervalRef.current = window.setInterval(() => {
rafRef.current = window.requestAnimationFrame(() => {
callbackRef.current();
});
}, delay);
// Clear interval and RAF if the components is unmounted or the delay changes:
return () => {
window.clearInterval(intervalRef.current || 0);
window.cancelAnimationFrame(rafRef.current || 0);
};
}
}, [delay]);
// In case you want to manually clear the interval or RAF from the consuming component...:
return [intervalRef, rafRef];
}
import React, { useEffect, useRef } from 'react';
/**
* Use setTimeout with Hooks in a declarative way.
*
* @see https://stackoverflow.com/a/59274757/3723993
* @see https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
export function useTimeout(
callback: React.EffectCallback,
delay: number | null,
): React.MutableRefObject<number | null> {
const timeoutRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
// Remember the latest callback:
//
// Without this, if you change the callback, when setTimeout kicks in, it
// will still call your old callback.
//
// If you add `callback` to useEffect's deps, it will work fine but the
// timeout will be reset.
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
// Set up the timeout:
useEffect(() => {
if (typeof delay === 'number') {
timeoutRef.current = window.setTimeout(() => callbackRef.current(), delay);
// Clear timeout if the components is unmounted or the delay changes:
return () => window.clearTimeout(timeoutRef.current || 0);
}
}, [delay]);
// In case you want to manually clear the timeout from the consuming component...:
return timeoutRef;
}
@superjose
Copy link

Awesome!!! Thanks a bunch!!

I'd add on the useRef a null as this will allow for the correct typing and will signal React that we will own the ref.
DefinitelyTyped/DefinitelyTyped#31065 (comment)

  const timeoutRef = useRef<number>();

Becomes:

  const timeoutRef = useRef<number | null>();

@Danziger
Copy link
Author

Thanks! Just updated that and added a new useThrottledRAF hook. 🚀

@Danziger
Copy link
Author

Danziger commented Nov 2, 2021

These hooks have been moved to an NPM package you can now directly install in your projects: https://www.npmjs.com/package/@swyg/corre.

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