ESC

Type to search the knowledge base.

useIdle Hook

Detect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.

intermediate6 min read
  • react
  • hooks
  • events
  • interview

A session-lock modal that never appears is usually not a timer bug. It is a mount bug. You subscribed to mousemove and keydown, and you only setTimeout inside those handlers. The user loads the page and walks away. No event ever fires, so idle never fires.

useIdle is a boolean that becomes true after ms of no input. The hook must start the timer on mount, reset it on activity, and clear both the timer and the listeners on unmount. visibilitychange belongs in the set: a hidden tab is not an active user, even if the last mousemove was one second ago.

This is not useInterval. You do not want a tick. You want a timeout you can restart. It is also not requestIdleCallback, which is a scheduler hint for leftover frame time.

  • Mount starts the timer. If nobody touches the page, idle still fires after ms.
  • Activity (mousemove, keydown, scroll, touchstart) sets idle to false and restarts the timeout.
  • visibilitychange lives on document. Hidden: treat as idle (or pause). Visible: treat as activity.
  • Cleanup is two resources: clearTimeout and removeEventListener for every type you added, same capture/passive flags.
  • Keep ms in a ref so the subscribe effect can stay []. Restart the wait on ms change only if the new delay must apply immediately.

Reset a timer on input, and the first idle fire after mount

import { useEffect, useRef, useState } from 'react';

const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'scroll', 'touchstart'];

function useIdle(ms = 60_000) {
  const [idle, setIdle] = useState(false);
  const msRef = useRef(ms);
  msRef.current = ms;

  useEffect(() => {
    let id;

    function startTimer() {
      clearTimeout(id);
      if (msRef.current == null) return;
      id = setTimeout(() => {
        setIdle(true);
      }, msRef.current);
    }

    function onActivity() {
      setIdle(false);
      startTimer();
    }

    function onVisibility() {
      if (document.visibilityState === 'hidden') {
        clearTimeout(id);
        setIdle(true);
        return;
      }
      onActivity();
    }

    const opts = { passive: true };
    for (const type of ACTIVITY_EVENTS) {
      window.addEventListener(type, onActivity, opts);
    }
    document.addEventListener('visibilitychange', onVisibility);

    startTimer();

    return () => {
      clearTimeout(id);
      for (const type of ACTIVITY_EVENTS) {
        window.removeEventListener(type, onActivity, opts);
      }
      document.removeEventListener('visibilitychange', onVisibility);
    };
  }, [ms]);

  return idle;
}
function SessionLock() {
  const idle = useIdle(5 * 60_000);
  if (!idle) return null;
  return (
    <div role="dialog" aria-labelledby="lock-title">
      <h2 id="lock-title">You are idle</h2>
    </div>
  );
}

Mount sequence:

Step Timer idle
First render not started (effects have not run) false
Effect runs started for ms false
No events for ms fires true
keydown restarted false
Unmount clearTimeout + remove listeners n/a

If you skip startTimer() at the bottom of the effect, the table’s third row never happens until the first input. A kiosk page that nobody touches stays “active” forever. That is the interview.

setIdle(false) on every mousemove is correct. When idle is already false, React bails out with Object.is. Do not read layout in that handler. Restarting a timeout is cheap; measuring the DOM is not.

Why these events, and why passive

Event Target Why
mousemove window Mouse users who never type
keydown window Keyboard-only users
scroll window Reading without moving the cursor
touchstart window Touch devices. mousemove does not stand in for a tap.
visibilitychange document Tab switch, minimize, phone lock

scroll on window does not see overflow inside a div. If the app is a single scrolling panel, attach to that node (pass a target, or use useEventListener per type).

passive: true tells the browser you will not preventDefault. That matters for touchstart and scroll. Matching the same object (or at least the same capture flag) on removeEventListener is required. Capture defaults to false. See capture, bubble, once, and passive.

visibilitychange is not on window. Subscribe on document. When the tab is hidden, this implementation marks idle immediately and clears the wait. When it becomes visible, it marks active and restarts. The product fork:

  1. Hidden means idle (lock the session, pause a video). What the code above does.
  2. Hidden means pause the timer (do not lock someone who alt-tabbed for ten seconds, but do not keep counting either).
  3. Hidden is ignored (timer keeps running in the background). Simple, often wrong on mobile.

Say which one you shipped. visibilitychange and page hide is the lifecycle reference.

Latest ms, and pause with null

The subscribe effect lists [ms]. Changing the timeout (or passing null to pause) runs cleanup, which clearTimeouts the in-flight wait, then resubscribes. That is the same pause trick as useInterval: null skips setTimeout inside startTimer. Idle detection is not a 60 fps subscription, so tearing down five listeners when the timeout changes is fine. Resetting the clock is the spec here.

msRef still tracks the latest wait if a handler fires during the same commit. Do not keep [] deps and advertise pause: an already-queued timeout would still fire setIdle(true).

Strict Mode runs setup, cleanup, setup. Missing clearTimeout means two timers. The first one still fires and can mark idle while the second wait is only half done. Missing removeEventListener duplicates handlers: one mousemove resets twice, which is mostly harmless, and leaks across navigations, which is not (Memory Leaks in SPAs).

Footguns

Timer only in the activity handler. Mount never starts it. Idle requires a first event. Name this before you write code.

mousemove without throttle as a performance panic. Restarting a timeout 60 times a second is cheap. Reading layout in that handler is not. Do not add a throttle until you have measured. If you do, throttle the reset, not the idle commit.

Listening for mousemove only. Keyboard and touch users never go “active” after mount except via keydown / touchstart. Include them.

blur / focus instead of visibilitychange. Iframe focus and devtools can blur the window without the user leaving. Visibility is the signal for “can they see this tab.”

Conditional hook. if (lockEnabled) useIdle(ms) breaks Rules of Hooks. Pass ms: null so the effect cleanup clears the timer and startTimer does not schedule another.

Initial true. Starting idle on the first paint flashes the lock modal before the effect runs. Start false, start the timer, let ms elapse.

Interview out-loud answer

Idle is a timeout I restart on input, not an interval. I start that timeout in the mount effect so a user who never touches the page still goes idle. I listen for mousemove, keydown, scroll, and touchstart on window, and visibilitychange on document. Hidden tabs become idle immediately in my default; coming back is activity. Cleanup clears the timeout and removes the same listeners with the same passive flag. I keep ms in a ref if I do not want to rebind, and I never put the hook behind an if.

Further reading

Related guides