useIdle Hook
Detect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- 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 tofalseand restarts the timeout.visibilitychangelives ondocument. Hidden: treat as idle (or pause). Visible: treat as activity.- Cleanup is two resources:
clearTimeoutandremoveEventListenerfor every type you added, same capture/passive flags.- Keep
msin a ref so the subscribe effect can stay[]. Restart the wait onmschange 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:
- Hidden means idle (lock the session, pause a video). What the code above does.
- Hidden means pause the timer (do not lock someone who alt-tabbed for ten seconds, but do not keep counting either).
- 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, andtouchstartonwindow, andvisibilitychangeondocument. 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 keepmsin a ref if I do not want to rebind, and I never put the hook behind anif.
Further reading
- MDN:
Document.visibilityState - MDN:
visibilitychange - MDN:
setTimeout - react.dev: Synchronizing with Effects
- Page Visibility (HTML)
Related on this site
- useEventListener Hook: stable handler ref, rebind only when the target changes
- useInterval Hook: a tick you do not reset; idle is the opposite
- visibilitychange and Page Hide: hidden vs blur
- useEffect Fundamentals: cleanup owns the timer
- Capture, Bubble, once, and passive: passive scroll/touch
- Memory Leaks in SPAs: leftover window listeners
- requestIdleCallback: not this hook
- Custom Hooks Design: extract the subscription, not the modal JSX
Related guides
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.
- useInterval HookA setInterval that sees the latest callback without restarting the timer when the callback identity changes. Dan Abramov’s ref pattern, named.