Skip to content

useIdle

Edit

Detects inactivity from configurable window events and resets cleanly on new activity.

useIdle.ts
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
export type UseIdleOptions = {
enabled?: boolean;
initialState?: boolean;
events?: readonly (keyof WindowEventMap)[];
onIdle?: (this: void) => void;
onActive?: (this: void) => void;
};
export type UseIdleReturn = {
isIdle: boolean;
reset: () => void;
};
const defaultActivityEvents = [
"keydown",
"pointerdown",
"pointermove",
"scroll",
"touchstart",
"focus",
] as const satisfies readonly (keyof WindowEventMap)[];
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
/** Marks the user idle after a period without configured window activity. */
export const useIdle = (
timeoutMs: number,
options: UseIdleOptions = {},
): UseIdleReturn => {
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
throw new RangeError("timeoutMs must be a finite, non-negative number");
}
const enabled = options.enabled ?? true;
const events = [...new Set(options.events ?? defaultActivityEvents)];
const eventsKey = events.join("\u0000");
const [isIdle, setIsIdle] = useState(options.initialState ?? false);
const isIdleRef = useRef(isIdle);
const onIdleRef = useRef(options.onIdle);
const onActiveRef = useRef(options.onActive);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const mountedRef = useRef(false);
useIsomorphicLayoutEffect(() => {
onIdleRef.current = options.onIdle;
onActiveRef.current = options.onActive;
}, [options.onActive, options.onIdle]);
useIsomorphicLayoutEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const clearTimer = useCallback(() => {
if (timerRef.current !== undefined) clearTimeout(timerRef.current);
timerRef.current = undefined;
}, []);
const scheduleIdle = useCallback(() => {
clearTimer();
if (!mountedRef.current || !enabled) return;
timerRef.current = setTimeout(() => {
timerRef.current = undefined;
if (mountedRef.current && !isIdleRef.current) {
isIdleRef.current = true;
setIsIdle(true);
if (onIdleRef.current !== undefined) {
Reflect.apply(onIdleRef.current, undefined, []);
}
}
}, timeoutMs);
}, [clearTimer, enabled, timeoutMs]);
const reset = useCallback(() => {
if (!mountedRef.current) return;
if (isIdleRef.current) {
isIdleRef.current = false;
setIsIdle(false);
if (onActiveRef.current !== undefined) {
Reflect.apply(onActiveRef.current, undefined, []);
}
}
scheduleIdle();
}, [scheduleIdle]);
useEffect(() => {
if (typeof window === "undefined" || !enabled) {
clearTimer();
isIdleRef.current = false;
setIsIdle(false);
return;
}
const handleActivity = () => {
reset();
};
for (const eventName of events) {
window.addEventListener(eventName, handleActivity, { passive: true });
}
scheduleIdle();
return () => {
clearTimer();
for (const eventName of events) {
window.removeEventListener(eventName, handleActivity);
}
};
}, [
clearTimer,
enabled,
eventsKey,
reset,
scheduleIdle,
]);
return { isIdle, reset };
};
Terminal
wget -O src/hooks/useIdle.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useIdle.ts
useIdle.example.tsx
import { useIdle } from "./useIdle";
export const ActivityStatus = () => {
const { isIdle } = useIdle(60_000);
return <p>Status: {isIdle ? "Away" : "Active"}</p>;
};
  • timeoutMs (number) — Finite, non-negative inactivity period.
  • options.enabled (boolean, default: true) — Enables listeners and the timer.
  • options.initialState (boolean, default: false) — Initial idle state.
  • options.events (readonly (keyof WindowEventMap)[]) — Activity events. Defaults to keyboard, pointer, scroll, touch, and focus activity.
  • options.onIdle (() => void) — Called on the active-to-idle transition.
  • options.onActive (() => void) — Called when activity ends an idle period.

An object with isIdle and reset(). Resetting marks the user active and restarts the inactivity period. Disabling or unmounting removes every listener and timer.