← ui

use-poll

ui

ask a server again on a cadence while the answer is seen, never twice at once

npx shadcn add @ag/use-poll
Already installed? Preview an update
npx shadcn add @ag/use-poll --dry-run

Your 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 and element (if given) is on screen. every runs 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. Returns data (kept through a failure), error, loading, updated and live. Unmounted, the request under way is aborted through its signal. The machine is framework-free in lib/poll-core (createPoll).
Updated 2026-10-12 · release notes

also called polling, refetch, auto refresh

shelf foundations

uses use-on-screen

the demo above, verbatim

use-poll.demo.tsx
"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-poll.ts
"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 }
}
poll-core.ts
// 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 })
    },
  }
}