Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * 一条重试链所处的位置。`waiting` 是这个 hook 相对 `useAsync` 多出来的那一态:
 * 上一次尝试已经失败、下一次还没开始,中间那段退避等待是**可见、可跳过、可取消**的。
 */
export type RetryStatus = "idle" | "running" | "waiting" | "success" | "failed"

/**
 * 放弃的原因。两者对用户的行动指导完全不同,所以必须分开:
 * - `exhausted` — 错误可重试,但试满了 `attempts` 次。文案是"稍后再试"。
 * - `rejected` — `shouldRetry` 判定这个错误重试也没用(400、422、鉴权失败)。

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-retry.json

Prompt

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, and attempts: 1 is 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 as busy instead 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 explicit cancel() is how a consumer asks for the other behaviour.

On This Page