useAsync
A one-call async state machine — idle/pending/success/error with last-call-wins race protection, AbortSignal cancellation and unmount-safe resolves.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-async.jsonPrompt
Build a React + TypeScript "useAsync" hook (React only, no data-fetching
library). It manages ONE async call — it is deliberately not a cache layer.
Contract
- `useAsync<TData, TArgs extends unknown[] = []>(asyncFn, options?)`.
- `asyncFn: (signal: AbortSignal, ...args: TArgs) => Promise<TData>` — the
AbortSignal is ALWAYS the first parameter, business arguments follow. A
function that cannot be cancelled just ignores it (`async () => {...}` is
assignable because TS allows declaring fewer parameters).
- `options: { immediate?: boolean; args?: TArgs; onSuccess?: (data: TData)
=> void; onError?: (error: Error) => void }`, all optional, `immediate`
defaults to false.
- Returns `{ data, error, status, isPending, isSuccess, isError, run, reset }`
where `status: "idle" | "pending" | "success" | "error"`, `data: TData |
null`, `error: Error | null`.
- The four states are mutually exclusive and the three fields live in ONE
state object so they always update together: `success` implies `error` is
null, `error` implies `data` is null. There is no torn frame where the
status flipped but the payload has not arrived yet.
- `run(...args: TArgs): Promise<AsyncOutcome<TData>>` where `AsyncOutcome` is
`{ status: "success"; data } | { status: "error"; error } | { status:
"stale" }`. `run` NEVER rejects — a failure resolves with the error
variant, so `await run()` needs no try/catch.
- `run` and `reset` are referentially stable (`useCallback` with an empty
dependency array), safe as `onClick` handlers or effect dependencies.
Behavior
- Race guard, last call wins. Every `run()` increments an internal
requestId ref and captures it. After the await, if the current requestId
no longer matches, the result is discarded: no setState, no `onSuccess` /
`onError`, and `run` resolves `{ status: "stale" }`. A slow earlier
request can therefore never overwrite a faster later one.
- Cancellation. Each `run()` creates a fresh AbortController, aborts the
previous one, and passes `controller.signal` as the first argument to
`asyncFn`. `reset()` and unmount abort too. Aborting is an optimisation
(stop wasted network work); correctness comes from the requestId guard, so
an `asyncFn` that ignores the signal still behaves correctly.
- Abort noise never becomes a fake error: the requestId check runs BEFORE the
caught value is inspected, so the AbortError raised by a superseded call is
discarded rather than written into `error`.
- Unmount safety. A `mountedRef` is set to true INSIDE the mount effect body
and false in its cleanup. (Setting it false only in cleanup is the classic
bug: under StrictMode's mount → cleanup → mount, the live instance would
read false forever and never leave `pending`.) Every await re-checks it, so
a late resolve never calls setState on an unmounted component.
- Latest-ref for everything callable. `asyncFn`, `onSuccess`, `onError` and
`args` are stored in refs refreshed on every render and never appear in a
dependency array — consumers pass inline arrows, and without this the
`immediate` effect would re-fire on every render.
- No setState in an effect body. When `immediate` is true the INITIAL status
is already `"pending"`, and the mount effect starts the call from a
`queueMicrotask` guarded by a local `cancelled` flag (also collapsing
StrictMode's double invocation into a single request). Every real setState
happens after an await.
- `immediate` re-runs whenever it changes, so `false → true` works as a
"go" switch. `args` is read from a ref at fire time only: changing `args`
does NOT re-run. To refetch when a dependency changes, call `run(...)`
from your own effect.
- `reset()` invalidates the in-flight call (bumps requestId + aborts) and
sets the state back to `idle`.
Rendering & styling
- The hook renders nothing; consumers own all UI. Suggested wiring: disable
the action button while `isPending` and swap its icon for a spinner with
`animate-spin motion-reduce:animate-none`; render `error.message` in a
`border-destructive/30 bg-destructive/10 text-destructive` panel; use
`text-muted-foreground` for idle/placeholder copy and `font-mono` +
`tabular-nums` for any status or id output. Semantic tokens only — no
hardcoded colours — so the states inherit the host theme in light and dark.
- Accessibility: give the async region `aria-busy={isPending}`, announce
errors through `role="alert"` (or the app's live region), and keep the
trigger a real `button` with a visible `focus-visible` ring.
Customization levers
- Keep-previous-data — `run()` currently clears `data` when it enters
`pending` (exclusive states, easiest to reason about). For a stale-while-
revalidate feel, change that one setState to preserve `prev.data` and
render `isPending && data` as a dimmed refresh state.
- Retry — add `retries` / `retryDelay` options and loop inside `run` before
the requestId check; keep the guard so a retried older call still loses to
a newer one.
- `immediate` + `args` — flip `immediate` from a boolean to a `deps` array if
you want automatic refetching; that is the first step toward a query
library, so weigh it against just adopting TanStack Query.
- Error normalisation — swap the `toError` helper for your app's error type
(parse an API envelope, attach a request id) so `error` is domain-shaped
rather than a bare `Error`.
- Outcome shape — `run` resolving `{ status: "stale" }` is what lets a call
site know its own call was superseded; drop that variant and resolve
`TData | undefined` if the call sites never branch on it.Concepts
- Last-call-wins request id — each call captures a monotonically increasing id before it awaits and compares it afterwards; the comparison, not the abort, is what guarantees correctness, so a slow first response can never overwrite a fast second one even when the underlying API cannot be cancelled.
- Stale as a first-class outcome —
runresolves with{ status: "stale" }when its result was thrown away (superseded, reset, or unmounted). Call sites that want to navigate or toast after a successful save branch on that instead of reading state, which by then belongs to a newer call. - Abort as an optimisation, not the guard — the AbortController exists to stop wasted network work and to let
asyncFnbail early; because the id check runs before the caught value is inspected, the AbortError of a superseded call is discarded rather than surfacing as a fake error. - Unmount-safe resolve —
mountedRefis set true inside the mount effect body and false in cleanup, then re-checked after every await; the common bug of only setting it false in cleanup makes StrictMode's remounted instance believe it is dead and leaves it stuck inpending. - Latest-ref callbacks —
asyncFn,onSuccess,onErrorandargslive in refs refreshed each render and never enter a dependency array, which is what makes it safe for consumers to pass inline arrow functions to animmediatehook without it re-firing on every render. - State machine, not a cache — there is no key, no cache entry, no background refresh and no invalidation here; the hook owns exactly one call's lifecycle. The moment you need shared or revalidated server state, that is the signal to adopt TanStack Query rather than grow this hook.