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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-retry.jsonPrompt
Build a React + TypeScript "useRetry" hook (React only, no data-fetching
library). It drives ONE async call plus the waits between its retries — it is
deliberately not a cache layer and not a poller.
Contract
- `backoffDelay(attempt: number, options?: BackoffOptions): number` is exported
as a standalone PURE function, so a consumer can chart or assert a schedule
without failing a request first. `BackoffOptions = { baseDelay?: number
(300); factor?: number (2); maxDelay?: number (30000); jitter?: "none" |
"full" | "equal" ("full"); random?: () => number (Math.random) }`.
`attempt` is the 1-based number of the attempt that just FAILED, so the
ceiling is `min(maxDelay, baseDelay * factor^(attempt - 1))`; `"none"`
returns the ceiling, `"full"` returns `random() * ceiling`, `"equal"`
returns `ceiling / 2 + random() * ceiling / 2`.
- `useRetry<TData, TArgs extends unknown[] = []>(fn, options?)`.
- `fn: (signal: AbortSignal, attempt: number, ...args: TArgs) =>
Promise<TData>` — AbortSignal first, 1-based attempt number second, business
args after. Each attempt gets a FRESH signal.
- `options` extends `BackoffOptions` with: `attempts?: number` (total calls
INCLUDING the first, default 3, so 1 disables retrying); `tick?: number`
(how often the countdown refreshes, default 100 ms, 0 = no intermediate
ticks); `shouldRetry?: (error: Error, attempt: number) => boolean`;
`signal?: AbortSignal` (consumer-owned cancellation); `onSuccess?: (data,
attempt) => void`; `onRetry?: (error, { attempt, nextAttempt, delay }) =>
void`; `onFailure?: (error, { attempt, reason }) => void`.
- Returns `{ data, error, status, reason, attempt, attempts, delay, retryIn,
isRunning, isWaiting, isPending, run, retryNow, cancel }`.
- `status: "idle" | "running" | "waiting" | "success" | "failed"`.
`waiting` is the state that makes this hook worth having: the backoff gap is
first-class, not a frozen spinner. `reason: "exhausted" | "rejected" | null`
is non-null only while `failed`.
- All seven state fields live in ONE state object so they always commit
together — there is never a frame where `status` says success but `data` has
not arrived, and `error` deliberately SURVIVES into `waiting`/`running` so
the UI can say why it is retrying.
- `run(...args: TArgs): Promise<RetryOutcome<TData>>` where `RetryOutcome` is
`{ status: "success"; data; attempt } | { status: "failed"; error; attempt;
reason } | { status: "cancelled" } | { status: "busy" }`. `run` NEVER
rejects, so `await run()` needs no try/catch.
- `run`, `retryNow` and `cancel` are referentially stable (useCallback with an
empty dependency array), safe as effect dependencies or memoized props.
Behavior
- The chain: attempt 1 runs immediately. On rejection, ask `shouldRetry(error,
attempt)`; if false, stop with `reason: "rejected"`. Else if `attempt >=
attempts`, stop with `reason: "exhausted"`. Else compute
`backoffDelay(attempt, options)`, fire `onRetry`, enter `waiting`, count
down, then run attempt + 1. `shouldRetry` is consulted on the LAST attempt
too — only to label the failure, because "a 400 that would never succeed" and
"a 503 we ran out of budget for" need different copy — so it must stay pure.
- Why a predicate and not a boolean: retrying a 400/422 five times cannot fix
a malformed body, it just multiplies the failure by five and makes the user
wait through four pointless round trips. Retrying a 503/429 usually works.
- Why jitter: an outage makes every client fail in the same millisecond, so an
unjittered schedule brings every client BACK in the same millisecond and
re-kills the service that just recovered. Full jitter spreads the herd.
- The countdown is anchored to a DEADLINE (`Date.now() + delay`) and every
wake-up recomputes `deadline - Date.now()`, instead of subtracting one tick
each time: background tabs get their timers throttled to about once a
minute, and a subtract-per-tick implementation under-counts and lies about
the time left. The clock is only ever read inside timers and handlers, never
during render — `retryIn` starts at 0, so SSR and hydration agree.
- One chain at a time. `run()` reads AND writes a busy ref inside the same
synchronous block, so a double click cannot open a second chain; the second
call resolves `{ status: "busy" }` instead of silently superseding — a retry
chain may already have caused server-side effects, so refusing is safer than
restarting. To restart deliberately: `cancel()` then `run()`.
- Cancellation. `cancel()` invalidates the chain id, aborts the in-flight
attempt's controller, settles the pending wait, and drops back to `idle`
(it also clears a settled success/failure). Aborting the consumer-supplied
`options.signal` does exactly the same; passing an already-aborted signal
makes `run()` refuse. Unmount runs the same teardown. In every case the
in-flight `run()` resolves `{ status: "cancelled" }`, writes no state and
fires no callback.
- `retryNow()` skips the rest of the current wait and starts the next attempt
immediately; it is a no-op outside `waiting`. Skip, deadline-reached and
cancel all funnel into ONE settle function guarded by a `settled` boolean
read and written synchronously, so a click that lands in the same frame as
the deadline cannot run the next attempt twice.
- Cleanup: the settle function calls `clearTimeout` before resolving, and the
unmount effect calls it — no timer, no pending resolve and no `setState`
survives unmount. A stale resolve (superseded chain, cancelled chain,
unmounted component) is detected by comparing the captured chain id with the
current one, and is dropped before any state write.
- Options are read from a latest-ref at the moment they are used, never
captured when `run()` starts: inline arrow `shouldRetry`/`onRetry` props cost
nothing, and raising `attempts` mid-wait takes effect on the next decision.
- Degenerate inputs clamp instead of throwing: `attempts < 1` becomes 1,
`factor < 1` becomes 1, negative delays become 0, a `random()` that returns
NaN or leaves [0, 1) is clamped (an unclamped NaN would turn setTimeout into
a busy loop). If a jittered delay rounds to 0 ms, the `waiting` state is set
and overwritten inside the same synchronous block, so React batches it away
and no "waiting" frame ever flashes.
Rendering & styling
- The hook renders nothing. Consumers own the UI; use semantic tokens only:
`bg-muted` for the countdown track, `bg-primary` for its fill,
`text-destructive` + `border-destructive/40` + `bg-destructive/5` for a
failed panel, `text-muted-foreground` for secondary lines, `cn()` for every
className merge.
- ARIA contract for the consumer's panel: put `role="status"` on the element
carrying the PHASE message ("retrying after a 503"), and keep the ticking
number out of the live region (`aria-hidden` on the countdown row) — a value
that changes ten times a second would flood a screen reader. Mark the busy
container `aria-busy` while `isPending`.
- Keyboard/focus: the controls are ordinary buttons (Enter/Space). Any control
whose enabled-ness flips while the user may be standing on it — "Retry now"
becomes inert the instant the wait ends — must use `aria-disabled` plus a
guard inside the handler, NOT the native `disabled` attribute, which blurs
the focused element to `<body>`. Never unmount the focused control on a
status change; change its label instead.
- Motion: the countdown is state-driven, not animated, so it works unchanged
under `prefers-reduced-motion`. If a spinner is added, pair it with
`motion-reduce:animate-none`; the progress fill should not rely on a CSS
transition to be readable.
Customization levers
- Schedule shape: `baseDelay` / `factor` / `maxDelay` / `jitter`. Interactive
actions want a short `baseDelay` (300-900 ms) and `maxDelay` a few seconds;
background sync wants `baseDelay` in seconds and `maxDelay` in minutes.
`jitter: "equal"` when a guaranteed minimum gap matters, `"none"` only in
tests. Inject a seeded `random` to make a schedule reproducible.
- Retry policy: swap `shouldRetry` for your own error taxonomy (status code,
error `name`, a `retriable` flag from the API envelope). Honour a
`Retry-After` header by reading it in `fn`, storing it, and returning it
from a custom schedule.
- Render budget: raise `tick` to 250-1000 ms (or 0) if a re-render per 100 ms
is too expensive; the countdown just gets coarser, the timing does not
change.
- UI surface: the same state drives a full panel (status pill + attempt
counter + progress bar + Retry now / Cancel) or a single line of text — the
hook has no opinion. `attempt` / `attempts` give "attempt 2 of 5",
`reason` picks between "try again later" and "fix the request".
- Pairing: `useAsync` for the one-shot calls that must not retry,
`use-interval` / `use-poll` for steady-cadence polling; this hook only owns
failure-driven backoff.Concepts
- Attempt budget vs delay schedule — two independent dials that are constantly confused. The budget (
attempts) answers how many times, counting the first call; the schedule (baseDelay,factor,maxDelay) answers how far apart. Changing one never silently changes the other, andattempts: 1is the honest way to say "call it once, no retries". - shouldRetry as a classifier — the predicate splits errors into worth another round trip (503, 429, a dropped socket) and permanently broken (400, 422, a declined card). Retrying the second kind cannot succeed; it only multiplies the failure and makes the user wait through the whole schedule for a verdict that was already final on attempt one.
- Full jitter — a shared outage makes every client fail in the same millisecond. Without randomisation they also come back in the same millisecond and knock the recovering service over again. Full jitter picks uniformly from zero up to the exponential ceiling, trading a slightly unpredictable single wait for a herd that arrives spread out.
- Interruptible wait — the backoff gap is a real state, not a
sleep()nobody can reach. Skipping it (the user pressed "retry now"), cancelling it and unmounting during it all funnel through one settle function that clears the timer first and can only fire once, so the next attempt can never be started twice. - Deadline-anchored countdown — the time left is recomputed from a stored deadline on every wake-up rather than decremented per tick, because background tabs get their timers throttled to roughly once a minute. Come back after a minute away and the countdown tells the truth instead of insisting there are still 40 seconds to go.
- One chain, refused not superseded — a second
run()while a chain is live resolves asbusyinstead of quietly replacing it. A half-finished retry chain may already have written to a server, so "you already started this" is a safer answer than an invisible restart; an explicitcancel()is how a consumer asks for the other behaviour.
useCookie
A cookie-as-state hook with typed attributes, cross-instance sync, an SSR-safe default snapshot, and writes that refuse instead of silently vanishing over the 4 KB cap.
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.