ask a server again on a cadence while the answer is seen, never twice at once
npx shadcn add @ag/use-pollAlready installed? Preview an update
npx shadcn add @ag/use-poll --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
usePoll(ask, { every, element, enabled, initial }), asking a server again on a cadence only while the tab is visible andelement(if given) is on screen.everyruns from the end of the last request, so it never asks twice at once.refresh()asks now; during a request it waits for it and asks once more, so it resolves with an answer asked for after the call. Returnsdata(kept through a failure),error,loading,updatedandlive. Unmounted, the request under way is aborted through its signal. The machine is framework-free inlib/poll-core(createPoll).
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/base-nova/ui/button"
import { RelativeTime } from "@/registry/base-nova/ui/relative-time"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { Sparkline } from "@/registry/base-nova/ui/sparkline"
import {
Stat,
StatChart,
StatLabel,
StatNote,
StatValue,
} from "@/registry/base-nova/ui/stat"
import { Status } from "@/registry/base-nova/ui/status"
import { Switch } from "@/registry/base-nova/ui/switch"
import { usePoll } from "@/registry/base-nova/ui/use-poll"
// #region server
/** A stand-in for an API: visitors online now, answered after 300 to 900 ms (it
* honours the abort), failing once when told to. It counts what it was asked. */
function makeServer() {
let online = 42
let failNext = false
const s = {
asked: 0,
failOnce: () => {
failNext = true
},
online: (signal: AbortSignal) =>
new Promise<number>((resolve, reject) => {
s.asked++
const answer = window.setTimeout(
() => {
if (failNext) {
failNext = false
reject(new Error("503 from /api/online"))
return
}
online = Math.max(
0,
online + Math.round((Math.random() - 0.45) * 6),
)
resolve(online)
},
300 + Math.random() * 600,
)
signal.addEventListener("abort", () => {
window.clearTimeout(answer)
reject(signal.reason)
})
}),
}
return s
}
// #endregion
// #region online
/** Visitors online, asked every 3 s while the tab is visible and the tile on screen;
* the last answers drawn as its trend. */
function Online() {
const [server] = React.useState(makeServer)
const [tile, setTile] = React.useState<HTMLElement | null>(null)
const [on, setOn] = React.useState(true)
const poll = usePoll(server.online, {
every: 3000,
element: tile,
enabled: on,
})
const [trend, setTrend] = React.useState<number[]>([])
const online = poll.data
React.useEffect(() => {
if (online !== undefined) setTrend((t) => [...t.slice(-29), online])
}, [online])
return (
<div className="flex w-72 flex-col gap-3">
<Stat ref={setTile}>
<StatLabel>online now</StatLabel>
<StatValue>{poll.data ?? "–"}</StatValue>
<StatNote className="flex flex-wrap items-baseline gap-x-2">
<Status tone={poll.error ? "bad" : poll.live ? "ok" : "neutral"}>
{poll.error ? "failing" : poll.live ? "live" : "paused"}
</Status>
{poll.updated !== null && (
<span>
updated <RelativeTime at={poll.updated} />
</span>
)}
</StatNote>
<StatChart>
<Sparkline values={trend} label="visitors online, last answers" />
</StatChart>
</Stat>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={poll.loading}
onClick={() => poll.refresh()}
>
{/* Both words in one cell, the other hidden: the button is as wide as
the longer, so nothing beside it moves when it changes. */}
<span className="grid">
<span
className={cn("[grid-area:1/1]", poll.loading && "invisible")}
>
refresh
</span>
<span
aria-hidden={!poll.loading}
className={cn("[grid-area:1/1]", !poll.loading && "invisible")}
>
asking…
</span>
</span>
</Button>
<Button variant="ghost" size="sm" onClick={server.failOnce}>
fail the next
</Button>
<Switch checked={on} onCheckedChange={setOn} size="sm">
polling
</Switch>
</div>
{/* Under the controls, so its coming and going moves nothing above it. */}
{poll.error && (
<p className="m-0 text-[13px] text-current/60">
{poll.error.message}; the last answer is kept and it asks again on its
cadence.
</p>
)}
</div>
)
}
// #endregion
// #region offscreen
/** Asked every second only while its row is on screen: scroll it away and the
* count of requests stops. */
function Watched() {
const [server] = React.useState(makeServer)
const [row, setRow] = React.useState<HTMLElement | null>(null)
const poll = usePoll(server.online, { every: 1000, element: row })
return (
<div
ref={setRow}
className="flex items-center justify-between gap-4 rounded-lg bg-foreground/5 px-4 py-3 text-[13px]"
>
<Status tone={poll.live ? "ok" : "neutral"}>
{poll.live ? "live" : "paused"}
</Status>
<span className="font-mono text-xs tabular-nums">
{poll.data ?? "–"} online, {server.asked} asked
</span>
</div>
)
}
// #endregion
export default function UsePollDemo() {
return (
<div className="space-y-8">
<Sample
name="online"
glance
title="a live number"
description="asked every 3 s from the end of the last answer, never two at once. refresh asks now; fail the next shows a failure keeping the last answer; polling off stops it. switch tabs and it pauses."
with="server online"
>
<Online />
</Sample>
<Sample
name="offscreen"
title="only while on screen"
description="scroll the box: once the row leaves the viewport it asks nothing, and the count of requests stops."
with="server offscreen"
>
<ScrollArea className="h-48 w-80 space-y-2 px-2 py-5 text-[13px] text-current/55">
{Array.from({ length: 8 }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed run of filler rows
<p key={i}>row {i + 1}, above</p>
))}
<Watched />
{Array.from({ length: 8 }, (_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed run of filler rows
<p key={i}>row {i + 10}, below</p>
))}
</ScrollArea>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import * as React from "react"
import { createPoll, type PollState } from "@/registry/base-nova/lib/poll-core"
import { useOnScreen } from "@/registry/base-nova/ui/use-on-screen"
// LIVE NUMBERS WITHOUT A SOCKET: ask the server again on a cadence, only while
// someone can see the answer, never twice at once.
//
// const stats = usePoll((signal) => fetch("/api/stats", { signal }).then(json), {
// every: 30_000,
// element, optional: only while this element is on screen
// })
// stats.data stats.error stats.loading stats.updated stats.live stats.refresh()
//
// It asks while the tab is visible and, given `element` (held by a callback ref, as
// use-on-screen takes it), while that element is on screen, and `enabled` is not
// false. Hidden, it asks nothing; shown again, it asks at once if the last answer is
// older than `every`, else when it will be. The cadence, the one request at a time,
// what `refresh()` resolves with, and what a failure keeps are lib/poll-core's.
//
// `data` is the last answer (kept through a failure), `error` what went wrong since,
// `loading` whether a request is under way (the first one included: no data and
// loading is the first load), `updated` when the last answer arrived, an instant for
// relative-time ("updated 12s ago"), `live` whether it is asking on its cadence now
// (for a "paused" beside the numbers). `initial` is an answer already in hand (a
// server render's) and when it was read.
//
// The request is the latest function passed, so it may close over props; what it
// asks for changing is a new poll, by React's `key` on the component that polls
// (the old answer must not show under the new question). Unmounted, the request
// under way is aborted through its signal.
export type { PollState }
export interface UsePollOptions<T> {
/** Ms between the end of one request and the start of the next. */
every: number
/** Only while this element is on screen (callback ref). Default the tab alone. */
element?: Element | null
/** Asks only while true. Default true. */
enabled?: boolean
/** An answer already in hand and when it was read (ms since the epoch). */
initial?: { data: T; at: number }
}
export type UsePoll<T> = PollState<T> & {
/** Asking on its cadence now: the tab visible, the element on screen, enabled. */
live: boolean
/** Ask now; a request under way is waited for and then asked again. Resolves once
* an answer asked for after the call has arrived (or failed). */
refresh: () => Promise<void>
}
export function usePoll<T>(
ask: (signal: AbortSignal) => Promise<T>,
{ every, element, enabled = true, initial }: UsePollOptions<T>,
): UsePoll<T> {
const latest = React.useRef(ask)
React.useLayoutEffect(() => {
latest.current = ask
})
const [poll] = React.useState(() =>
createPoll<T>((signal) => latest.current(signal), { every, initial }),
)
const state = React.useSyncExternalStore(
poll.subscribe,
poll.state,
poll.state,
)
const live = useOnScreen(element) && enabled
React.useEffect(() => poll.retime(every), [poll, every])
React.useEffect(() => poll.want(live), [poll, live])
React.useEffect(() => () => poll.halt(), [poll])
return { ...state, live, refresh: poll.refresh }
}// A POLL: ask for something again on a cadence while it is wanted, never two requests
// at once, and at once when asked to. Framework-free; ui/use-poll is its React face,
// which wants it while the tab is visible and an element is on screen.
//
// const poll = createPoll((signal) => fetchStats(signal), { every: 30_000 })
// poll.subscribe(() => draw(poll.state()))
// poll.want(true) ask now (or when the last answer is `every` old), then on
// poll.refresh() ask now; resolves with an answer asked for after the call
// poll.halt() abort what is under way, ask nothing more until wanted
//
// THE CADENCE runs from the END of the last request: the next starts `every` ms after
// the last one finished, so a slow server is never asked twice at once and a request
// that takes longer than `every` is not followed by a burst. A failure keeps the
// cadence (no backoff, which would be a tuned constant standing in for what the
// server says) and keeps the last answer: `data` is what was last known, `error` what
// went wrong since, cleared by the next success.
//
// REFRESH while a request is under way does not start a second: it waits for that one
// and then asks once more, so what it resolves with was asked for after it was called
// (a refresh after a save sees the save). Any number of refreshes meanwhile share that
// one request.
//
// WANTING and HALTING: `want(false)` lets a request under way finish and asks nothing
// more; `want(true)` asks at once if the last answer is older than `every`, else when
// it will be. `halt()` aborts the request under way (its signal), and its answer, if
// it comes anyway, is dropped; an aborted request is no answer, so wanting again asks
// at once. A request's errors are `Error`s; anything else thrown screams.
export interface PollState<T> {
/** The last answer, kept through any failure after it. */
data: T | undefined
/** What the last request threw; null once one succeeds. */
error: Error | null
/** A request is under way. */
loading: boolean
/** When the last answer arrived (ms since the epoch); null before the first. */
updated: number | null
}
export interface PollOptions<T> {
/** Ms between the end of one request and the start of the next. */
every: number
/** An answer already in hand (a server render's) and when it was read: the first
* request waits until it is `every` old. */
initial?: { data: T; at: number }
/** The clock. Default `Date.now`. */
now?: () => number
}
export interface Poll<T> {
state(): PollState<T>
/** Called after every change of state; returns the unsubscribe. */
subscribe(listener: () => void): () => void
want(wanted: boolean): void
refresh(): Promise<void>
retime(every: number): void
halt(): void
}
function assertEvery(every: number) {
if (!(Number.isFinite(every) && every > 0))
throw new Error(`poll: every is ms above 0, got ${every}`)
}
export function createPoll<T>(
ask: (signal: AbortSignal) => Promise<T>,
{ every: firstEvery, initial, now = Date.now }: PollOptions<T>,
): Poll<T> {
assertEvery(firstEvery)
let every = firstEvery
let state: PollState<T> = {
data: initial?.data,
error: null,
loading: false,
updated: initial?.at ?? null,
}
/** When the last request that answered ended, or the initial answer was read. */
let last: number | null = initial?.at ?? null
let wanted = false
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | null = null
let running: Promise<void> | null = null
/** The refresh waiting for the request under way to end. */
let again: Promise<void> | null = null
/** Bumped by `halt`: a refresh queued before it does not ask after it. */
let epoch = 0
const listeners = new Set<() => void>()
const set = (next: Partial<PollState<T>>) => {
state = { ...state, ...next }
for (const listener of listeners) listener()
}
const schedule = () => {
clearTimeout(timer)
timer = undefined
if (!wanted || running) return
const wait = last === null ? 0 : Math.max(0, last + every - now())
timer = setTimeout(run, wait)
}
function run(): Promise<void> {
if (running) throw new Error("poll: a second request while one runs")
clearTimeout(timer)
timer = undefined
const own = new AbortController()
controller = own
set({ loading: true })
running = ask(own.signal)
.then(
(data) => {
if (own.signal.aborted) return
last = now()
set({ data, error: null, loading: false, updated: last })
},
(error: unknown) => {
if (own.signal.aborted) return
if (!(error instanceof Error))
throw new Error(
`poll: a request threw ${String(error)}, which is no Error`,
)
last = now()
set({ error, loading: false })
},
)
.finally(() => {
running = null
controller = null
schedule()
})
return running
}
return {
state: () => state,
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
want(next) {
wanted = next
schedule()
},
refresh() {
if (!running) return run()
const at = epoch
const under = running
again ??= under.then(() => {
again = null
if (at !== epoch) return
return running ?? run()
})
return again
},
retime(next) {
assertEvery(next)
every = next
schedule()
},
halt() {
epoch++
wanted = false
again = null
clearTimeout(timer)
timer = undefined
controller?.abort()
if (state.loading) set({ loading: false })
},
}
}