Hooks

usePoll

A polling hook that pauses in hidden tabs, catches up on return, skips a tick while a request is still in flight, and backs off through a dynamic interval.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * **数据态**,不是"忙不忙"——在途与否看 `isFetching`。轮询的关键在于两者分开:
 * 第二次之后的每一次取数都发生在"已经有数据"的背景下,不该把界面打回骨架屏。
 *
 * - `idle` —— 一次都还没发过(被 `enabled: false` 拦住,或 `immediate: false` 还没到第一拍)。
 * - `loading` —— 正在取数**且手上还没有数据**(首屏;失败后重试时也是它)。
 * - `success` —— 最近一次取数成功,`data` 是它的结果,`error` 为 null。
 * - `error` —— 最近一次取数失败。**`data` 会保留**上一次成功的值:轮询挂掉一拍
 *   不等于数据没了,界面应该继续显示旧值 + 一条"更新失败"的提示,而不是清空。
 */

Installation

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

Prompt

Build a React + TypeScript "usePoll" hook (React only — no data-fetching
library; uses AbortController, setInterval and the Page Visibility API). It
polls ONE endpoint on a cadence that behaves; it is deliberately not a cache.

Contract
- `usePoll<TData>(fetcher, interval, options?): UsePollResult<TData>`.
- `fetcher: (signal: AbortSignal) => Promise<TData>` — the AbortSignal is the
  only parameter, same convention as `useAsync`. Implementations may ignore it.
- `interval: number | null | ((context) => number | null)` — milliseconds, or
  `null` for "schedule nothing", or a resolver called with
  `{ consecutiveErrors, error, hasData }`. Same nullable-delay shape as
  `useInterval`. The resolver runs during render, so it must be pure.
- `options`: `enabled = true`, `immediate = true` (fetch as soon as the hook
  first becomes active), `catchUpOnResume = true` (fetch immediately when it
  becomes active again), `pauseWhenHidden = true`, `onSuccess(data)`,
  `onError(error)`.
- Returns `{ data, error, status, isFetching, isPolling, isPaused,
  isTabHidden, lastSuccessAt, consecutiveErrors, skippedTicks, fetchCount,
  nextIntervalMs, refresh, pause, resume }`.
- `status: "idle" | "loading" | "success" | "error"` is the DATA state and
  `isFetching` is the ACTIVITY flag — they are orthogonal. `status: "loading"`
  only while there is no data yet; a refresh over existing data keeps
  `status: "success"` and merely flips `isFetching`, so the panel never falls
  back to a skeleton after the first load.
- `refresh(): Promise<PollOutcome<TData>>` never rejects; it resolves to
  `{ status: "success" | "error" | "skipped" | "stale" }`. `refresh`, `pause`
  and `resume` keep a stable identity for the component's lifetime.

Behavior
- Skip, never stack. A single `inFlightRef` boolean is read AND written
  synchronously at the top of the fetch routine, before any `await`, so two
  callers can never pass the gate together. A tick that arrives while a
  request is in flight is dropped and counted in `skippedTicks`; a manual
  `refresh()` in the same situation resolves as `{ status: "skipped" }` and is
  NOT counted (it is not a tick). Queuing instead would flood the server and
  let an older response overwrite a newer one.
- Hidden tab. Subscribe to `document.visibilitychange` through
  `useSyncExternalStore` with a module-level store (one listener per page no
  matter how many instances) and a `getServerSnapshot` that returns "visible".
  While hidden and `pauseWhenHidden`, no interval exists. On return, fire one
  catch-up fetch and start a fresh full interval. An already in-flight request
  is NOT aborted when the tab hides — its result is still fresh data.
- Backoff. After each rejection `consecutiveErrors` increments and the
  resolver is re-run, so the cadence is a pure function of the failure count:
  `Math.min(60000, 5000 * 2 ** consecutiveErrors)`. A success resets it to 0
  and the cadence snaps back to baseline. A resolver returning `null` suspends
  polling entirely — `isPolling` false, `nextIntervalMs` null — and only a
  manual `refresh()` gets through. Non-finite or `<= 0` values are treated as
  `null` (never `setInterval(fn, 0)`) with a one-time dev `console.warn`.
- Failure keeps the data. On error, `data` is left untouched and only `error`
  and `status` change, so a consumer renders "last good value + update failed"
  instead of blanking the panel for one bad tick.
- Cancellation and cleanup. Each fetch owns an `AbortController` and a
  monotonic request id. On unmount: clear the interval and the activation
  timer (effect cleanups), bump the request id, abort the controller. Every
  `await` is followed by an "is this still the current request and is the
  component still mounted" check, so a late resolve writes no state and fires
  no callback. The visibility listener is removed when the last subscriber
  unsubscribes.
- Manual refresh restarts the clock: `refresh()` bumps a cadence epoch that is
  part of the interval effect's identity, so the tick that was about to fire is
  rescheduled a full interval later instead of landing back-to-back.
- Latest-ref everything the consumer passes inline (`fetcher`, `onSuccess`,
  `onError`, `immediate`, `catchUpOnResume`): they are synced in a bare effect
  and never appear in a dependency array, otherwise an inline arrow function
  would tear down and rebuild the interval on every render.
- No setState in an effect body: the first/catch-up fetch is dispatched from a
  `setTimeout(…, 0)` created in the effect and cleared in its cleanup, which
  also makes StrictMode's mount → cleanup → mount fire exactly one request.
- Timestamps (`lastSuccessAt`) are read with `Date.now()` inside the resolve
  callback, never during render, so server and client first paint agree.

Rendering & styling
- The hook renders nothing. For the UI built on it, use semantic tokens only:
  `bg-card`/`border` panels, `text-muted-foreground` for metadata,
  `text-destructive` + `bg-destructive/10` for the failure banner, `bg-primary`
  for the live badge, `cn()` for every className merge.
- Accessibility contract of the surface: put `aria-busy={isFetching}` on the
  region being refreshed; render the error as `role="alert"` and a passive
  "updated at HH:MM:SS" line as `role="status"` so a screen reader is not
  interrupted on every tick; give any spinner `aria-hidden` plus a text label
  next to it. Under `prefers-reduced-motion` the spinner must stop
  (`animate-spin motion-reduce:animate-none`) while the textual state keeps
  conveying everything — polling must remain fully usable with motion off.
- Pause/Resume/Retry controls must use `aria-disabled` plus a handler guard,
  never the native `disabled` attribute: the control the user is standing on
  goes inactive the moment they click it, and a disabled element throws focus
  back to `<body>`. If a polled panel is unmounted by a control, move focus to
  a deliberate successor first.
- Keyboard map: nothing bespoke — every control stays in the tab order, Tab /
  Shift+Tab move between them, Enter and Space activate the focused one. That
  is precisely why the inactive guard lives in the click handler: keyboard
  activation is routed through the same handler, so a guard placed anywhere
  else would let a keyboard user fire an action the mouse cannot.

Customization levers
- Cadence policy is the main lever: a constant for a calm dashboard, a
  resolver for backoff, `hasData` in the context for "poll fast until the first
  payload lands, then relax", `error` in the context to branch on 429 vs 5xx.
- Give-up policy: return `null` after N failures and surface a "Retry" button
  wired to `refresh()`; or keep polling forever and only slow down.
- `pauseWhenHidden: false` for a wall-mounted dashboard nobody focuses;
  `catchUpOnResume: false` when a stale-for-a-few-seconds view is cheaper than
  a thundering herd of returning tabs; `immediate: false` when the first
  payload already came from the server render.
- `enabled` is the gate for "this panel is collapsed", "the user has not picked
  an id yet", or a route that is not visible — polling stops without unmounting
  the component or losing `data`.
- Multi-endpoint pages: call the hook once per endpoint with different
  cadences (fast for status, slow for the summary) rather than merging them
  into one fetcher; each instance keeps its own in-flight gate and backoff.
- If you need caching, deduplication across components or key-based
  invalidation, stop here and use TanStack Query's `refetchInterval` — do not
  grow this hook into a worse cache layer.

Concepts

  • Skip, don't stack — the in-flight boolean is read and written in the same synchronous block before any await, so a tick that lands mid-request is dropped rather than queued. Stacking is not just extra load: the older response finishes last and overwrites the newer one, which is how a "live" panel starts showing values that go backwards.
  • Catch-up fetch on resume — coming back from a hidden tab (or from pause()) fires one immediate request and only then restarts the cadence, so the user never stares at a value that is a whole interval old. It is the same "refetch on focus" behaviour data libraries ship, minus the library.
  • Interval as data, not as a constant — the cadence is a pure function of consecutiveErrors, so exponential backoff, a ceiling, and "give up entirely" (null) are all expressible without any imperative retry loop. Success resets the counter and the cadence snaps back on its own.
  • Stale data survives an error — a failed tick updates error and status but never clears data. One bad response is a hiccup in a stream of good ones; blanking the panel for it turns a hiccup into an outage.
  • Voided request id over "cancelled" — abort is best-effort (a fetcher may ignore the signal), so correctness rests on the id check after every await: if it changed, or the component unmounted, the result is dropped without touching state or firing callbacks.
  • Cadence epoch — a manual refresh() bumps a counter folded into the interval effect's identity, restarting the clock so the pending tick cannot land back-to-back with the fetch the user just asked for.

On This Page