/ src / hooks / useIdleTimer.ts
useIdleTimer.ts
 1  // Copyright (c) 2026 VPL Solutions. All rights reserved.
 2  // Licensed under the MIT License. See LICENSE for details.
 3  
 4  import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
 5  
 6  const ACTIVITY_EVENTS = [
 7    'mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'click',
 8  ] as const;
 9  
10  interface UseIdleTimerOptions {
11    /** Milliseconds of inactivity before onIdle fires. */
12    timeout: number;
13    /** Called once when the idle threshold is reached. */
14    onIdle: () => void;
15    /** Set false to disable the timer entirely (e.g. when auth is off). */
16    enabled?: boolean;
17  }
18  
19  /**
20   * Tracks user activity across the window. Fires onIdle after `timeout` ms
21   * of silence. Resets automatically on any activity event.
22   *
23   * Returns a `reset` function to manually restart the countdown.
24   */
25  export function useIdleTimer({ timeout, onIdle, enabled = true }: UseIdleTimerOptions) {
26    const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
27    // Keep onIdle stable across re-renders without restarting the effect.
28    const onIdleRef = useRef(onIdle);
29    useLayoutEffect(() => {
30      onIdleRef.current = onIdle;
31    });
32  
33    const reset = useCallback(() => {
34      if (timer.current) clearTimeout(timer.current);
35      timer.current = setTimeout(() => onIdleRef.current(), timeout);
36    }, [timeout]);
37  
38    useEffect(() => {
39      if (!enabled) return;
40  
41      reset();
42  
43      const handleActivity = () => reset();
44      ACTIVITY_EVENTS.forEach((e) =>
45        window.addEventListener(e, handleActivity, { passive: true }),
46      );
47  
48      return () => {
49        if (timer.current) clearTimeout(timer.current);
50        ACTIVITY_EVENTS.forEach((e) =>
51          window.removeEventListener(e, handleActivity),
52        );
53      };
54    }, [enabled, reset]);
55  
56    return { reset };
57  }