Hooks

usePageVisibility

An SSR-safe hook that subscribes to document visibility so polling, timers and video can pause when the tab goes to the background, and reports how long the user was away.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface PageVisibilityState {
  /** Whether the page is visible right now (the boolean projection of `visibilityState === "visible"`). */
  isVisible: boolean
  /** The raw `document.visibilityState`, for when you need the states beyond `"visible"`. */
  visibilityState: DocumentVisibilityState
  /** Timestamp (`Date.now()`) of the last time the page was switched away from. **Kept after
   *  returning**, so you can still show "you left at …" once back; `null` until the first
   *  switch away since mount. */
  hiddenSince: number | null
  /** How many times the page has become visible. If it was already visible at mount, that counts as 1. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-page-visibility.json

Prompt

Build a React + TypeScript "usePageVisibility" hook (no dependencies beyond
React; uses the browser's Page Visibility API — `document.visibilityState`
and the `visibilitychange` event).

Contract
- `usePageVisibility(options?: { onVisible?: (hiddenDurationMs: number) => void;
  onHidden?: (hiddenAt: number) => void }): PageVisibilityState`.
- `PageVisibilityState`:
  - `isVisible: boolean` — projection of `visibilityState === "visible"`.
  - `visibilityState: DocumentVisibilityState` — the raw string, for consumers
    that need more than the boolean.
  - `hiddenSince: number | null` — timestamp of the most recent switch away.
    It is *retained* after the user comes back (so a "you left at 14:03:22"
    line is still renderable on return) and is only `null` before the first
    switch away.
  - `visibleCount: number` — how many times the page has become visible; the
    initial visible session counts as 1.
- Both callbacks fire on real transitions only — never on mount or unmount,
  because mounting is not a visibility change.

Behavior
- Built on `useSyncExternalStore`, not `useEffect` + `setState`: page
  visibility is an external mutable source that changes outside React's render
  cycle, which is exactly what that hook exists for.
  - `subscribe(callback)`: attach to a module-level store (see below) and
    return a cleanup that detaches. Guard `typeof document === "undefined"`
    and return a no-op in that case.
  - `getSnapshot()`: return a *cached* module-level object. Never build a new
    object per call and never read `document` here — `getSnapshot` runs during
    render, so a fresh object each time makes React think the store keeps
    changing and re-renders forever, and reading `document`/`Date.now()` in
    render is impure.
  - `getServerSnapshot()`: return one frozen constant meaning "visible"
    (`isVisible: true`, `hiddenSince: null`, `visibleCount: 0`). The server has
    no document to query; almost every first paint happens in a foreground tab,
    so assuming visible avoids hydrating into a wrongly-paused UI.
- One module-level store shared by every instance on the page: a `Set` of
  listeners, ref-counted — attach the single `visibilitychange` listener when
  the set goes 0 → 1, remove it when it goes back to 0. N components mean one
  DOM listener and one shared snapshot, so `visibleCount` can't diverge between
  two components on the same page.
- On attach, re-sync the cached snapshot from the live `document` (this runs
  inside subscribe, i.e. in an effect, not during render) but do it *silently*:
  do not notify listeners and do not fire `onVisible`/`onHidden`. React
  re-reads `getSnapshot` right after `subscribe` returns and re-renders if the
  value changed, so the correct state still lands without faking a transition.
  The re-sync has to reconcile transitions that happened while nobody was
  subscribed (no listener was attached, so no event was recorded):
  - document hidden but the stored snapshot says visible → the hide was
    missed; reset `hiddenSince` to the attach timestamp. Do *not* keep an
    older `hiddenSince` from a previous round trip, or the next `onVisible`
    will report an away duration measured from minutes ago. The attach
    timestamp is an honest approximation — the browser does not expose when
    the tab was actually backgrounded before you started observing.
  - document hidden and the stored snapshot already says hidden → keep the
    original timestamp; re-subscribing must not restart the clock.
  - document visible but the stored snapshot says hidden → a return was
    missed; count it in `visibleCount` (but still fire no callback).
  Share this "next hiddenSince" rule between the event handler and the
  re-sync as one function; duplicating it is how the two paths drift.
- Event handling: read `document.visibilityState`, bail out when it equals the
  stored one (some browsers re-dispatch on focus/blur churn; deduping keeps
  `visibleCount` and `hiddenSince` from counting one switch twice), otherwise
  build the next snapshot object, then notify listeners over a *copy* of the
  set so a listener that unsubscribes mid-notification cannot corrupt the loop.
- Timestamps (`Date.now()`) are taken only inside the event handler / subscribe
  — never during render.
- `onVisible` / `onHidden` use the latest-ref pattern: sync them into refs in
  an effect with no dependency array and read `ref.current` inside the store
  listener. They must never enter a dependency array — consumers pass inline
  arrow functions, and a new identity per render would tear down and re-attach
  the subscription on every render.
- Cleanup: unsubscribing removes the listener from the set and, at zero
  subscribers, removes the DOM listener. Nothing is left attached.

Rendering & styling
- The hook renders nothing — it returns state. Consumers branch on it: gate a
  `setInterval`/`requestAnimationFrame` effect on `isVisible` (return early
  while hidden so the cleanup clears the timer), pause a `video` element, or
  render a "paused" badge. Anything visual uses semantic tokens
  (`bg-muted text-muted-foreground` for the paused state, `bg-primary` for the
  live one), never hard-coded colors, and any spinner respects
  `motion-reduce:animate-none`.

Customization levers
- Away threshold — ignore short flicks away by comparing `hiddenDurationMs`
  in `onVisible` against a threshold (e.g. only refetch after > 30s hidden),
  instead of refetching on every micro-switch.
- Resume strategy — `isVisible` gating alone resumes on the *next* interval
  tick; add `onVisible` to fire one immediate refetch on return (the
  "refetch on focus" behaviour data libraries ship). Pick one or both.
- Server snapshot — flip `getServerSnapshot` to "hidden" only if your app is
  mounted primarily in background tabs (rare); the visible default keeps
  first paint consistent with the server markup.
- Wider lifecycle — if you also need to stop work when the page is frozen or
  bfcache'd, add `pagehide`/`freeze`/`resume` listeners inside the same store
  and widen the snapshot; the subscribe/snapshot shape does not change.
- Per-instance vs shared store — the module-level store is what makes N
  instances cost one listener; if you need an isolated counter per component,
  move the store into a `useRef` factory and drop the ref counting.

Concepts

  • Page visibility as an external store — visibility flips outside React's render cycle, so it is handed to useSyncExternalStore as three primitives (subscribe / snapshot / server snapshot) rather than being mirrored into state by an effect. No setState in an effect body, tear-safe under concurrent rendering.
  • Cached snapshot, not a fresh objectgetSnapshot runs during render and must return a referentially stable value; the store rebuilds its snapshot object only when a real visibilitychange lands, so idle renders are free and the "getSnapshot should be cached" infinite loop can't happen.
  • Ref-counted module store — one Set of listeners for the whole page: the visibilitychange listener is attached when the first consumer subscribes and removed when the last one leaves, so ten components mean one DOM listener and one shared visibleCount instead of ten diverging ones.
  • Silent re-sync on attach — subscribing re-reads the live document to correct the snapshot, but deliberately does not fire onVisible/onHidden: mounting is not a transition. React's own post-subscribe snapshot check delivers the corrected value without a fabricated event. The re-sync also repairs switches that happened while nobody was subscribed — a hide it never saw resets hiddenSince to now (so the next away duration isn't measured from a stale round trip), and a return it never saw still counts in visibleCount.
  • Latest-ref callbacksonVisible/onHidden are copied into refs after each render and read at event time, so consumers can pass inline arrow functions without the subscription being torn down and rebuilt every render.
  • Retained hiddenSince vs. away duration — the timestamp survives the return trip so "you left at 14:03:22" is renderable once the user is back (a value cleared on return would be unreadable, since nobody can see the screen while it is hidden); the precise gap is delivered separately as hiddenDurationMs to onVisible.
  • Visible ≠ focusedvisibilitychange answers "is this document hidden from the user" (other tab, minimized, screen locked, app backgrounded on mobile). A page that is still on screen while another app has focus stays visible; pausing on focus loss needs window blur/focus instead.

On This Page