Build a React hook that tracks whether the user has gone idle. useIdle(timeout) returns false while the user is active and flips to true once timeout milliseconds pass with no activity at all — no mouse movement, clicks, key presses, touches, scrolls, or window resizes. The moment any of those happen, the countdown restarts and the value snaps back to false. This is the pattern behind "you've been inactive — log out?" banners and auto-pausing video players.
function useIdle(timeout: number): boolean;
timeout is the inactivity threshold in milliseconds. The hook returns a single boolean: true when idle, false when active.
function SessionGuard() {
// Considered idle after 30 seconds with no activity.
const idle = useIdle(30000);
return idle
? <Banner>Still there? You'll be logged out soon.</Banner>
: <App />;
}
// timeout = 1000
// t=0 mount → false (active)
// t=1000 no activity for 1000ms → true (idle)
// t=1200 user moves the mouse → false (active, timer restarts)
// t=2200 no activity since t=1200 → true (idle again)
mousemove, mousedown, keydown, touchstart, scroll, and resize. Subscribe to all of them on window.timeout with zero activity. A burst of events that keeps arriving faster than timeout should keep it active indefinitely.false immediately and start a fresh countdown.You'll combine a single restartable timer with a set of activity listeners: every event clears and restarts the timer and marks the user active, and the timer firing is the only thing that marks them idle.
You want to know when a user has walked away. "Idle" doesn't mean a fixed moment — it means "nothing has happened for a while." So the clock can't run once and stop; it has to keep getting pushed forward every time the user does anything. Think of a kitchen timer you reset to zero each time someone walks past: it only ever rings if the room stays empty long enough. Here the "ring" is idle = true, the "walking past" is any of mousemove, mousedown, keydown, touchstart, scroll, or resize, and "long enough" is timeout milliseconds.
This is the first hook that needs both halves of the toolkit at once: a setTimeout (the countdown) and event listeners (the resets). Neither alone is enough — a timer with no listeners can't react to activity, and listeners with no timer have nothing to schedule.
There is exactly one timer at a time. When it fires, you go idle. Every activity listener does the same two things: set idle = false (the user is clearly here) and restart the timer from zero. Because the timer is cleared and re-created on each event, its deadline keeps sliding into the future as long as activity keeps coming. Idle only happens in the gap — a stretch of timeout ms where no listener fired and the timer was left alone to reach the end.
The obvious version schedules the idle timer once on mount and stops there:
const { useState, useEffect } = require('react');
function useIdle(timeout) {
const [idle, setIdle] = useState(false);
useEffect(() => {
const id = setTimeout(() => setIdle(true), timeout);
return () => clearTimeout(id);
}, []);
return idle;
}
This goes idle on a fixed schedule no matter what the user does. There are no listeners, so a mouse move or keystroke changes nothing — timeout ms after mount the timer fires and the hook reports idle even if the user has been typing the whole time. And once it's idle there's no path back to active, because nothing ever calls setIdle(false). The missing pieces are the listeners that reset the countdown, and the logic that restarts the timer on each event.
const { useState, useEffect, useRef } = require('react');
function useIdle(timeout) {
const [idle, setIdle] = useState(false);
// Hold the timer id in a ref so the same handler can clear the previous
// timer before scheduling the next one, across many events, without
// re-running the effect.
const timerRef = useRef(null);
useEffect(() => {
// (Re)start the countdown: cancel any pending timer, then schedule a fresh
// one. After `timeout` ms with nobody calling this again, idle flips true.
const start = () => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIdle(true), timeout);
};
// Any activity proves the user is here: wake up and restart the clock.
const onActivity = () => {
setIdle(false);
start();
};
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'resize'];
events.forEach((name) => window.addEventListener(name, onActivity));
start(); // begin the first countdown immediately
// Cleanup must tear down BOTH resources: the pending timer and every
// listener we added (same onActivity reference, so removal actually works).
return () => {
clearTimeout(timerRef.current);
events.forEach((name) => window.removeEventListener(name, onActivity));
};
}, [timeout]);
return idle;
}
module.exports = { useIdle };
The key shift from the naive version is that the timer is no longer a one-shot. The start helper clears then re-schedules, and every activity listener calls it — so each event slides the deadline forward and onActivity also resets idle to false. The timer id lives in a ref because the same listeners need to clear whatever the previous countdown was, call after call, without the effect re-running. The effect depends on [timeout] so changing the threshold rebuilds the countdown at the new rate.
Take useIdle(80) and a single mouse move at t=50:
idle is false. The effect attaches the six listeners and calls start(), scheduling a timer to fire at t=80.t=50, mouse moves. The mousemove listener runs onActivity: it sets idle = false (already false, no change) and calls start(), which clearTimeouts the t=80 timer and schedules a new one for t=130.t=80 arrives. Nothing happens — that timer was cleared at t=50. The naive version would have gone idle here; this one is still active.t=130 the live timer fires setIdle(true). The hook now returns true.t=140, user clicks. The mousedown listener runs onActivity: setIdle(false) wakes the hook back up, and start() schedules the next idle deadline for t=220.clearTimeout on the pending timer and removeEventListener for all six events, so nothing fires into the gone component.setTimeout(() => setIdle(true), timeout) goes idle on schedule regardless of activity — exactly the naive bug. Fix: clear and re-schedule the timer inside the activity handler so its deadline slides forward on every event.idle back to false. If onActivity only restarts the timer but never calls setIdle(false), the hook can never recover once it's gone idle. Fix: every activity handler sets idle = false and restarts the timer.removeEventListener than the one given to addEventListener removes nothing, leaking all six listeners. Fix: define onActivity once and pass that same reference to both add and remove.setIdle into a dead component, and leftover listeners keep handling events forever. Fix: the cleanup must clearTimeout AND remove every listener.mousemove and scroll can fire hundreds of times a second. Wrapping onActivity in a throttle (call at most once per ~200ms) cuts the work without changing the behavior, since you only need one reset per burst.{ idle, lastActive } (a timestamp updated in onActivity) lets a banner show "inactive for 4 minutes" instead of a bare boolean.visibilitychange and the document's activity, or syncing through localStorage/BroadcastChannel, lets several tabs of the same app agree on whether the user is truly away.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a React hook that tracks whether the user has gone idle. useIdle(timeout) returns false while the user is active and flips to true once timeout milliseconds pass with no activity at all — no mouse movement, clicks, key presses, touches, scrolls, or window resizes. The moment any of those happen, the countdown restarts and the value snaps back to false. This is the pattern behind "you've been inactive — log out?" banners and auto-pausing video players.
function useIdle(timeout: number): boolean;
timeout is the inactivity threshold in milliseconds. The hook returns a single boolean: true when idle, false when active.
function SessionGuard() {
// Considered idle after 30 seconds with no activity.
const idle = useIdle(30000);
return idle
? <Banner>Still there? You'll be logged out soon.</Banner>
: <App />;
}
// timeout = 1000
// t=0 mount → false (active)
// t=1000 no activity for 1000ms → true (idle)
// t=1200 user moves the mouse → false (active, timer restarts)
// t=2200 no activity since t=1200 → true (idle again)
mousemove, mousedown, keydown, touchstart, scroll, and resize. Subscribe to all of them on window.timeout with zero activity. A burst of events that keeps arriving faster than timeout should keep it active indefinitely.false immediately and start a fresh countdown.You'll combine a single restartable timer with a set of activity listeners: every event clears and restarts the timer and marks the user active, and the timer firing is the only thing that marks them idle.
You want to know when a user has walked away. "Idle" doesn't mean a fixed moment — it means "nothing has happened for a while." So the clock can't run once and stop; it has to keep getting pushed forward every time the user does anything. Think of a kitchen timer you reset to zero each time someone walks past: it only ever rings if the room stays empty long enough. Here the "ring" is idle = true, the "walking past" is any of mousemove, mousedown, keydown, touchstart, scroll, or resize, and "long enough" is timeout milliseconds.
This is the first hook that needs both halves of the toolkit at once: a setTimeout (the countdown) and event listeners (the resets). Neither alone is enough — a timer with no listeners can't react to activity, and listeners with no timer have nothing to schedule.
There is exactly one timer at a time. When it fires, you go idle. Every activity listener does the same two things: set idle = false (the user is clearly here) and restart the timer from zero. Because the timer is cleared and re-created on each event, its deadline keeps sliding into the future as long as activity keeps coming. Idle only happens in the gap — a stretch of timeout ms where no listener fired and the timer was left alone to reach the end.
The obvious version schedules the idle timer once on mount and stops there:
const { useState, useEffect } = require('react');
function useIdle(timeout) {
const [idle, setIdle] = useState(false);
useEffect(() => {
const id = setTimeout(() => setIdle(true), timeout);
return () => clearTimeout(id);
}, []);
return idle;
}
This goes idle on a fixed schedule no matter what the user does. There are no listeners, so a mouse move or keystroke changes nothing — timeout ms after mount the timer fires and the hook reports idle even if the user has been typing the whole time. And once it's idle there's no path back to active, because nothing ever calls setIdle(false). The missing pieces are the listeners that reset the countdown, and the logic that restarts the timer on each event.
const { useState, useEffect, useRef } = require('react');
function useIdle(timeout) {
const [idle, setIdle] = useState(false);
// Hold the timer id in a ref so the same handler can clear the previous
// timer before scheduling the next one, across many events, without
// re-running the effect.
const timerRef = useRef(null);
useEffect(() => {
// (Re)start the countdown: cancel any pending timer, then schedule a fresh
// one. After `timeout` ms with nobody calling this again, idle flips true.
const start = () => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setIdle(true), timeout);
};
// Any activity proves the user is here: wake up and restart the clock.
const onActivity = () => {
setIdle(false);
start();
};
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll', 'resize'];
events.forEach((name) => window.addEventListener(name, onActivity));
start(); // begin the first countdown immediately
// Cleanup must tear down BOTH resources: the pending timer and every
// listener we added (same onActivity reference, so removal actually works).
return () => {
clearTimeout(timerRef.current);
events.forEach((name) => window.removeEventListener(name, onActivity));
};
}, [timeout]);
return idle;
}
module.exports = { useIdle };
The key shift from the naive version is that the timer is no longer a one-shot. The start helper clears then re-schedules, and every activity listener calls it — so each event slides the deadline forward and onActivity also resets idle to false. The timer id lives in a ref because the same listeners need to clear whatever the previous countdown was, call after call, without the effect re-running. The effect depends on [timeout] so changing the threshold rebuilds the countdown at the new rate.
Take useIdle(80) and a single mouse move at t=50:
idle is false. The effect attaches the six listeners and calls start(), scheduling a timer to fire at t=80.t=50, mouse moves. The mousemove listener runs onActivity: it sets idle = false (already false, no change) and calls start(), which clearTimeouts the t=80 timer and schedules a new one for t=130.t=80 arrives. Nothing happens — that timer was cleared at t=50. The naive version would have gone idle here; this one is still active.t=130 the live timer fires setIdle(true). The hook now returns true.t=140, user clicks. The mousedown listener runs onActivity: setIdle(false) wakes the hook back up, and start() schedules the next idle deadline for t=220.clearTimeout on the pending timer and removeEventListener for all six events, so nothing fires into the gone component.setTimeout(() => setIdle(true), timeout) goes idle on schedule regardless of activity — exactly the naive bug. Fix: clear and re-schedule the timer inside the activity handler so its deadline slides forward on every event.idle back to false. If onActivity only restarts the timer but never calls setIdle(false), the hook can never recover once it's gone idle. Fix: every activity handler sets idle = false and restarts the timer.removeEventListener than the one given to addEventListener removes nothing, leaking all six listeners. Fix: define onActivity once and pass that same reference to both add and remove.setIdle into a dead component, and leftover listeners keep handling events forever. Fix: the cleanup must clearTimeout AND remove every listener.mousemove and scroll can fire hundreds of times a second. Wrapping onActivity in a throttle (call at most once per ~200ms) cuts the work without changing the behavior, since you only need one reset per burst.{ idle, lastActive } (a timestamp updated in onActivity) lets a banner show "inactive for 4 minutes" instead of a bare boolean.visibilitychange and the document's activity, or syncing through localStorage/BroadcastChannel, lets several tabs of the same app agree on whether the user is truly away.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.