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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-poll.jsonPrompt
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
errorandstatusbut never clearsdata. 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.
useRetry
A retry loop for one async call: exponential backoff with full jitter, a shouldRetry predicate, a live countdown to the next attempt, and cancellation that clears every timer.
useVisualViewport
A hook that tracks the visual viewport — size, offsets and pinch scale — and derives the keyboard occlusion so a bottom bar can sit above the on-screen keyboard.