Hooks

useAsync

A one-call async state machine — idle/pending/success/error with last-call-wins race protection, AbortSignal cancellation and unmount-safe resolves.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * The four states of one async call. They are **mutually exclusive**: on
 * `success` `error` is null, on `error` `data` is null — there is no torn frame
 * where "status is success but data hasn't landed yet" (all three fields live in
 * one state object and always update together).
 */
export type AsyncStatus = "idle" | "pending" | "success" | "error"

/**
 * What `run()` resolves to. **`run` never rejects**: a failure lands both in

Installation

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

Prompt

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 outcomerun resolves 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 asyncFn bail 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 resolvemountedRef is 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 in pending.
  • Latest-ref callbacksasyncFn, onSuccess, onError and args live 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 an immediate hook 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.

On This Page