hooks/idle.jsxjavascript
/*
Detects whether the user has interacted with the page within the last
`idleTime` ms. Intended to drive screensaver start/stop: consumers watch
`isIdle` and show the screensaver once it flips to true, hiding it again
on the next interaction.
Activity is tracked via a set of global DOM events. `visibilitychange` is
handled separately from the rest because it fires on `document` (not
`window`) and only counts as activity when the tab becomes visible again -
switching away should not keep postponing the idle timeout.
*/
import { useState, useEffect, useCallback, useRef } from "react";
const ACTIVITY_EVENTS = [
"mousemove",
"mousedown",
"mouseup",
"touchstart",
"touchmove",
"pointerdown",
"keydown",
];
/**
* @param {number} idleTime - Time in ms of inactivity after which `isIdle` becomes true.
* @returns {{isIdle: boolean, trigger: ?string, forceIdle: function(): void}} `trigger` is
* the event type that ended the last idle period ("init" on first mount), and is
* reset to null once idle again - useful for debugging what woke the app up. `forceIdle`
* shows the idle state immediately, without waiting for the timeout.
*/
export const useIdle = (idleTime) => {
const [isIdle, setIsIdle] = useState(false);
const [trigger, setTrigger] = useState(null);
const idleTimerRef = useRef();
const handleActivity = useCallback(
(event) => {
// Only Enter counts as keyboard activity (e.g. a remote/kiosk confirm button) -
// other keys are ignored on purpose.
if (event?.type === "keydown" && event?.key !== "Enter") return;
// Only record the event that ended the idle period, not every subsequent one.
setTrigger((prev) => prev ?? (event?.type || "init"));
setIsIdle(false);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => {
setIsIdle(true);
setTrigger(null);
}, idleTime);
},
// Deliberately excludes `trigger` - handleActivity must stay stable across
// idle/active transitions, otherwise every transition would tear down and
// re-register all listeners below.
[idleTime]
);
const handleVisibilityChange = useCallback(() => {
if (document.visibilityState === "visible") handleActivity();
}, [handleActivity]);
const forceIdle = useCallback(() => {
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
setIsIdle(true);
setTrigger(null);
}, []);
useEffect(() => {
ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, handleActivity));
document.addEventListener("visibilitychange", handleVisibilityChange);
const initTimer = setTimeout(handleActivity);
return () => {
clearTimeout(initTimer);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
ACTIVITY_EVENTS.forEach((event) => window.removeEventListener(event, handleActivity));
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [handleActivity, handleVisibilityChange]);
return { isIdle, trigger, forceIdle };
};