Visibility-aware polling hook
Poll an async function on an interval that pauses in hidden tabs, backs off on errors and cancels in-flight work on unmount.
React / Next.jsHooksreactpollinghookvisibility
Code
import { useCallback, useEffect, useRef, useState } from 'react';
type Options = { interval?: number; maxDelay?: number; enabled?: boolean };
export function usePolling<T>(fn: (signal: AbortSignal) => Promise<T>, { interval = 5000, maxDelay = 60000, enabled = true }: Options = {}) {
const [data, setData] = useState<T>();
const [error, setError] = useState<unknown>();
const fnRef = useRef(fn);
fnRef.current = fn;
const runNow = useRef<() => void>(() => {});
useEffect(() => {
if (!enabled) return;
const ctrl = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let failures = 0;
const tick = async () => {
clearTimeout(timer);
if (document.visibilityState === 'hidden') return; // resumed by visibilitychange
try {
setData(await fnRef.current(ctrl.signal));
setError(undefined);
failures = 0;
} catch (e) {
if (ctrl.signal.aborted) return;
setError(e);
failures += 1;
}
// Next run is scheduled only after this one finishes: no overlapping requests.
const delay = Math.min(maxDelay, interval * 2 ** failures);
if (!ctrl.signal.aborted) timer = setTimeout(tick, delay);
};
const onVisible = () => document.visibilityState === 'visible' && void tick();
runNow.current = () => void tick();
document.addEventListener('visibilitychange', onVisible);
void tick();
return () => {
ctrl.abort();
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [interval, maxDelay, enabled]);
const refresh = useCallback(() => runNow.current(), []);
return { data, error, refresh };
}
How it works
What it solves
Live lists such as incoming webhook requests, job status or order queues are often refreshed with a plain setInterval. That keeps hammering the server from background tabs, stacks overlapping requests when the server is slow, and retries failures at full speed. This hook polls politely.
How it works
The hook uses a recursive setTimeout instead of setInterval, so the next request is only scheduled after the current one finishes. That removes overlap by design. Each run receives an AbortSignal from an AbortController; on unmount or when options change, the effect aborts the in-flight call and clears the timer.
When document.visibilityState is hidden, the loop stops scheduling. A visibilitychange listener resumes it and triggers an immediate refresh when the tab returns, so users see fresh data without waiting for the next tick.
Failures increase the delay exponentially: after each consecutive error the wait doubles up to a cap, and a success resets it to the base interval. The latest fn is stored in a ref so changing the callback does not restart polling, and the returned refresh function lets a button force a poll right away.
Adapting it
- Add a
maxFailuresoption that stops polling and exposes the error. - Add jitter to the delay so many clients do not synchronise.
- Pause polling while the browser is offline by listening to the
onlineevent.
Pitfalls
- Your function must honour
signal, for example by passing it tofetch, or abort will not stop it. - Ignore
AbortErrorin your own error handling.
How to use it
- Save as
hooks/usePolling.ts. const { data, error, refresh } = usePolling((signal) => fetch('/api/requests', { signal }).then((r) => r.json()), { interval: 3000 }).- Render
dataand a refresh button callingrefresh(). - Set
enabled: falseto pause polling manually.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.