March 31, 2026

By ReactUse Team

Building Idle Detection and Session Management in React

Every application that deals with sensitive data — banking dashboards, healthcare portals, admin panels — needs to answer a deceptively simple question: is the user still there? If they walked away from their laptop with a patient record on screen, you should lock the session. If they switched to another tab during a long-running export, you might want to pause polling to save bandwidth. If they are watching a training video, the screen should stay awake. These are all facets of the same problem: understanding user presence and reacting to it.

In this post we will build four practical patterns from scratch, see exactly where the manual implementations get painful, and then replace them with concise hooks from ReactUse. By the end you will have production-ready solutions for session timeouts, background tab detection, screen wake locks, and return-to-tab notifications.

1. Session Timeout Warning with Idle Detection

The Manual Approach

Detecting idle state means tracking every signal that the user is active — mouse movement, keyboard input, touch events, scrolling — and resetting a timer each time any of them fires. Here is a naive implementation:

import { useCallback, useEffect, useRef, useState } from "react";

function useManualIdle(timeoutMs: number) {
  const [idle, setIdle] = useState(false);
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  const resetTimer = useCallback(() => {
    setIdle(false);
    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => setIdle(true), timeoutMs);
  }, [timeoutMs]);

  useEffect(() => {
    const events = ["mousemove", "keydown", "touchstart", "scroll"];
    events.forEach((evt) => window.addEventListener(evt, resetTimer));
    resetTimer(); // start the timer

    return () => {
      events.forEach((evt) => window.removeEventListener(evt, resetTimer));
      clearTimeout(timerRef.current);
    };
  }, [resetTimer]);

  return idle;
}

This works for a demo but falls apart in production. You are missing mousedown, pointerdown, wheel, and visibilitychange. Every event fires resetTimer, which calls setIdle(false) even if you are already not idle — causing unnecessary re-renders on every mouse pixel. There is no way to distinguish between “idle for 5 minutes” and “idle for 30 seconds” without adding more timers. And the timeout is not configurable without remounting the component.

The Hook Solution: useIdle

useIdle from ReactUse handles all of this in a single call:

import { useIdle } from "@reactuses/core";

function SessionManager() {
  const idle = useIdle(5 * 60 * 1000); // 5 minutes

  return idle ? <SessionWarningDialog /> : null;
}

The hook listens to the right set of DOM events, debounces resets internally, and returns a stable boolean. No timer juggling, no forgotten event types.

Building a Full Session Timeout Dialog

Let us combine useIdle with a countdown to build a real session warning:

import { useCallback, useEffect, useState } from "react";
import { useIdle } from "@reactuses/core";

const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
const WARNING_DURATION = 60; // 60 seconds to respond

function SessionTimeoutGuard({ onLogout }: { onLogout: () => void }) {
  const idle = useIdle(IDLE_TIMEOUT);
  const [countdown, setCountdown] = useState(WARNING_DURATION);

  useEffect(() => {
    if (!idle) {
      setCountdown(WARNING_DURATION);
      return;
    }

    const interval = setInterval(() => {
      setCountdown((prev) => {
        if (prev <= 1) {
          clearInterval(interval);
          onLogout();
          return 0;
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(interval);
  }, [idle, onLogout]);

  if (!idle) return null;

  return (
    <div className="session-overlay">
      <div className="session-dialog">
        <h2>Are you still there?</h2>
        <p>
          Your session will expire in <strong>{countdown}</strong> seconds
          due to inactivity.
        </p>
        <p>Move your mouse or press any key to stay signed in.</p>
        <div className="session-progress">
          <div
            className="session-progress-bar"
            style={{ width: `${(countdown / WARNING_DURATION) * 100}%` }}
          />
        </div>
      </div>
    </div>
  );
}

Because useIdle returns false the moment the user moves their mouse, the dialog dismisses automatically — no “Stay Signed In” button is needed (though you can add one). The countdown resets cleanly when idle flips back to false.

2. Pausing Background Work When the Tab Is Hidden

The Manual Approach

Many apps poll an API on an interval. When the user switches to another tab, those requests are wasted bandwidth. Detecting tab visibility manually requires the Page Visibility API:

import { useEffect, useState } from "react";

function useManualDocumentVisibility() {
  const [visibility, setVisibility] = useState<DocumentVisibilityState>(
    typeof document !== "undefined" ? document.visibilityState : "visible"
  );

  useEffect(() => {
    const handler = () => setVisibility(document.visibilityState);
    document.addEventListener("visibilitychange", handler);
    return () => document.removeEventListener("visibilitychange", handler);
  }, []);

  return visibility;
}

Simple enough for a single use, but you need to remember the SSR guard, and if you want to combine this with other signals (like window focus), you end up with multiple hooks and conditional logic scattered across your component.

The Hook Solution: useDocumentVisibility

useDocumentVisibility wraps the Page Visibility API with SSR safety built in:

import { useDocumentVisibility } from "@reactuses/core";

function PollingDashboard() {
  const visibility = useDocumentVisibility();

  useEffect(() => {
    if (visibility === "hidden") return;

    const interval = setInterval(() => {
      fetch("/api/metrics").then(/* update state */);
    }, 10_000);

    return () => clearInterval(interval);
  }, [visibility]);

  return <Dashboard />;
}

When the user switches tabs, visibility changes to "hidden", the effect cleans up, and polling stops. When they return, the effect re-runs and polling resumes. Zero wasted requests.

A Smarter Data-Pausing Pattern

For a more robust approach, combine visibility with a data freshness indicator:

import { useCallback, useEffect, useRef, useState } from "react";
import { useDocumentVisibility } from "@reactuses/core";

interface DashboardData {
  metrics: Record<string, number>;
  updatedAt: number;
}

function SmartPollingDashboard() {
  const visibility = useDocumentVisibility();
  const [data, setData] = useState<DashboardData | null>(null);
  const [stale, setStale] = useState(false);
  const lastFetchRef = useRef(0);

  const fetchData = useCallback(async () => {
    const res = await fetch("/api/dashboard");
    const json = await res.json();
    setData(json);
    setStale(false);
    lastFetchRef.current = Date.now();
  }, []);

  useEffect(() => {
    if (visibility === "hidden") {
      // Mark data as stale after 30 seconds in background
      const staleTimer = setTimeout(() => setStale(true), 30_000);
      return () => clearTimeout(staleTimer);
    }

    // Tab is visible -- fetch immediately if data is stale
    if (stale || Date.now() - lastFetchRef.current > 30_000) {
      fetchData();
    }

    // Resume normal polling
    const interval = setInterval(fetchData, 10_000);
    return () => clearInterval(interval);
  }, [visibility, stale, fetchData]);

  return (
    <div>
      {stale && <div className="stale-banner">Data may be outdated</div>}
      {data && <MetricsGrid metrics={data.metrics} />}
    </div>
  );
}

This pattern gives you: no background polling, instant refresh on tab return, and a stale-data indicator if the user was away for a long time.

3. Keeping the Screen Awake

The Manual Approach

The Screen Wake Lock API prevents the device screen from dimming or locking. It is critical for video players, presentation apps, recipe viewers, and any scenario where the user is looking at the screen but not touching the device:

import { useCallback, useEffect, useRef, useState } from "react";

function useManualWakeLock() {
  const [isActive, setIsActive] = useState(false);
  const wakeLockRef = useRef<WakeLockSentinel | null>(null);

  const request = useCallback(async () => {
    try {
      wakeLockRef.current = await navigator.wakeLock.request("screen");
      setIsActive(true);

      wakeLockRef.current.addEventListener("release", () => {
        setIsActive(false);
      });
    } catch (err) {
      console.error("Wake Lock request failed:", err);
    }
  }, []);

  const release = useCallback(async () => {
    await wakeLockRef.current?.release();
    wakeLockRef.current = null;
    setIsActive(false);
  }, []);

  // Re-acquire when tab becomes visible again
  useEffect(() => {
    const handleVisibility = () => {
      if (document.visibilityState === "visible" && isActive) {
        request();
      }
    };
    document.addEventListener("visibilitychange", handleVisibility);
    return () =>
      document.removeEventListener("visibilitychange", handleVisibility);
  }, [isActive, request]);

  return { isActive, request, release };
}

The gotcha with the Wake Lock API is that the browser automatically releases the lock when the tab becomes hidden. You have to re-acquire it when the tab becomes visible again — which is exactly the kind of edge case that gets forgotten in production.

The Hook Solution: useWakeLock

useWakeLock handles re-acquisition, error handling, and cleanup automatically:

import { useWakeLock } from "@reactuses/core";

function PresentationMode() {
  const { isActive, request, release } = useWakeLock();

  return (
    <button onClick={() => (isActive ? release() : request("screen"))}>
      {isActive ? "Screen will stay on" : "Allow screen to sleep"}
    </button>
  );
}

A “Keep Screen Awake” Toggle for Video Apps

Here is a complete component for a video or presentation app:

import { useWakeLock, useDocumentVisibility } from "@reactuses/core";
import { useEffect } from "react";

function VideoPlayer({ src }: { src: string }) {
  const { isActive, request, release } = useWakeLock();
  const visibility = useDocumentVisibility();

  // Automatically request wake lock when playing
  const handlePlay = () => {
    if (!isActive) request("screen");
  };

  const handlePause = () => {
    if (isActive) release();
  };

  return (
    <div className="video-container">
      <video
        src={src}
        onPlay={handlePlay}
        onPause={handlePause}
        controls
      />
      <div className="video-controls">
        <span className={`wake-indicator ${isActive ? "active" : ""}`}>
          {isActive ? "Screen lock prevented" : "Screen may sleep"}
        </span>
        {visibility === "hidden" && (
          <span className="background-notice">
            Video is playing in a background tab
          </span>
        )}
      </div>
    </div>
  );
}

When the user hits play, the screen stays awake. When they pause or navigate away, the lock is released. The hook handles re-acquisition when the tab becomes visible again — a detail that would take another 15 lines to implement manually.

4. Notify Users When They Return to the Tab

The Manual Approach

Suppose your app finishes a long task while the user is in another tab. You want to send a browser notification so they know to come back. Doing this manually requires combining the Notification API with visibility and focus detection:

import { useCallback, useEffect, useRef, useState } from "react";

function useManualNotifyOnReturn() {
  const [focused, setFocused] = useState(true);
  const pendingRef = useRef<string | null>(null);

  useEffect(() => {
    const onFocus = () => setFocused(true);
    const onBlur = () => setFocused(false);
    window.addEventListener("focus", onFocus);
    window.addEventListener("blur", onBlur);
    return () => {
      window.removeEventListener("focus", onFocus);
      window.removeEventListener("blur", onBlur);
    };
  }, []);

  const notify = useCallback(
    (title: string, body: string) => {
      if (focused) return; // user is already looking

      if (Notification.permission === "granted") {
        new Notification(title, { body });
      } else if (Notification.permission !== "denied") {
        Notification.requestPermission().then((perm) => {
          if (perm === "granted") {
            new Notification(title, { body });
          }
        });
      }
    },
    [focused]
  );

  return { focused, notify };
}

This misses edge cases: What if the user denied notification permission? What happens on mobile where focus/blur behave differently? What about cleaning up notifications when the user returns?

The Hook Solution: useWindowFocus + useWebNotification

Combining useWindowFocus and useWebNotification gives you clean, declarative control:

import { useWindowFocus, useWebNotification } from "@reactuses/core";

function TaskRunner() {
  const focused = useWindowFocus();
  const { isSupported, show, close } = useWebNotification({
    title: "",
    dir: "auto",
    lang: "en",
    tag: "task-complete",
  });

  const runTask = async () => {
    await performLongRunningTask();

    // Only notify if user is not looking at the tab
    if (!focused) {
      show({
        title: "Task Complete",
        body: "Your export is ready to download.",
      });
    }
  };

  return (
    <div>
      <button onClick={runTask}>Start Export</button>
      {!isSupported && (
        <p className="warning">
          Browser notifications are not supported.
        </p>
      )}
    </div>
  );
}

A Complete Notification System

Let us build a more realistic notification center that queues events while the user is away and notifies them upon return:

import { useCallback, useEffect, useRef, useState } from "react";
import { useWindowFocus, useWebNotification } from "@reactuses/core";

interface AppEvent {
  id: string;
  title: string;
  body: string;
  timestamp: number;
}

function NotificationCenter() {
  const focused = useWindowFocus();
  const { isSupported, show } = useWebNotification({
    title: "",
    dir: "auto",
    lang: "en",
    tag: "app-notification",
  });
  const [missedEvents, setMissedEvents] = useState<AppEvent[]>([]);
  const focusedRef = useRef(focused);

  // Keep ref in sync for use in callbacks
  useEffect(() => {
    focusedRef.current = focused;
  }, [focused]);

  // Simulate incoming events (replace with your WebSocket/SSE handler)
  const onServerEvent = useCallback((event: AppEvent) => {
    if (!focusedRef.current) {
      setMissedEvents((prev) => [...prev, event]);
    }
  }, []);

  // When user returns, show a summary notification
  useEffect(() => {
    if (focused && missedEvents.length > 0) {
      if (isSupported) {
        show({
          title: `${missedEvents.length} updates while you were away`,
          body: missedEvents.map((e) => e.title).join(", "),
        });
      }
      // Clear the queue -- user has seen the notification
      setMissedEvents([]);
    }
  }, [focused, missedEvents, isSupported, show]);

  return (
    <div className="notification-center">
      {missedEvents.length > 0 && (
        <div className="missed-badge">{missedEvents.length}</div>
      )}
    </div>
  );
}

This pattern is especially valuable for collaborative apps (like document editors or chat) where things happen while the user is in another tab.

Combining Everything: A Presence-Aware App Shell

The real power comes from composing these hooks together. Here is an app shell that handles session management, background optimization, and user notifications in one place:

import { useEffect, useCallback } from "react";
import {
  useIdle,
  useDocumentVisibility,
  useWindowFocus,
  useWakeLock,
  useWebNotification,
} from "@reactuses/core";

function AppShell({ children }: { children: React.ReactNode }) {
  const idle = useIdle(5 * 60 * 1000);
  const visibility = useDocumentVisibility();
  const focused = useWindowFocus();
  const { request: requestWakeLock, release: releaseWakeLock } = useWakeLock();
  const { show: showNotification } = useWebNotification({
    title: "",
    dir: "auto",
    lang: "en",
    tag: "app-shell",
  });

  // Session timeout
  useEffect(() => {
    if (idle) {
      // Start logout countdown or lock screen
    }
  }, [idle]);

  // Pause expensive work in background
  useEffect(() => {
    if (visibility === "hidden") {
      // Pause animations, polling, WebSocket heartbeat frequency
    }
  }, [visibility]);

  // Notify on return
  useEffect(() => {
    if (focused) {
      // Check for pending notifications, refresh stale data
    }
  }, [focused]);

  const userState = idle
    ? "idle"
    : visibility === "hidden"
      ? "background"
      : "active";

  return (
    <div className="app-shell" data-user-state={userState}>
      {idle && <SessionTimeoutOverlay />}
      {children}
    </div>
  );
}

Five hooks, each doing one thing well, composed together to create a presence-aware application. No manual event listeners, no timer bookkeeping, no SSR guards.

When to Use Each Hook

ScenarioHookWhat It Detects
Session timeoutuseIdleNo user input for N milliseconds
Pause background workuseDocumentVisibilityTab is hidden/visible
Detect tab switchuseWindowFocusWindow gained/lost focus
Keep screen awakeuseWakeLockScreen Wake Lock API
Browser notificationsuseWebNotificationNotification API

Installation

npm install @reactuses/core
# or
pnpm add @reactuses/core
# or
yarn add @reactuses/core

ReactUse provides 100+ hooks for React. Explore them all →