{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-async",
  "title": "useAsync",
  "description": "A one-call async state machine with last-call-wins race protection, AbortSignal cancellation and unmount-safe resolves.",
  "files": [
    {
      "path": "src/registry/hooks/use-async.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * The four states of one async call. They are **mutually exclusive**: on\n * `success` `error` is null, on `error` `data` is null — there is no torn frame\n * where \"status is success but data hasn't landed yet\" (all three fields live in\n * one state object and always update together).\n */\nexport type AsyncStatus = \"idle\" | \"pending\" | \"success\" | \"error\"\n\n/**\n * What `run()` resolves to. **`run` never rejects**: a failure lands both in\n * state and here, so `await run()` needs no try/catch.\n *\n * `stale` = \"this call wrote no state and fired no onSuccess/onError\". Three\n * ways to get it: (1) a later `run()` superseded it; (2) `reset()` cancelled it;\n * (3) the component unmounted before it landed. Callers use it to decide whether\n * to follow up at the call site (navigate, toast) instead of reading state —\n * by then state belongs to the newer call.\n */\nexport type AsyncOutcome<TData> =\n  | { status: \"success\"; data: TData }\n  | { status: \"error\"; error: Error }\n  | { status: \"stale\" }\n\n/**\n * The async function handed to `useAsync`. **The first parameter is always an\n * `AbortSignal`**; the call's own arguments, forwarded from `run(...)`, come\n * after. Functions that don't need cancellation can just ignore it\n * (`async () => {...}` type-checks — TS allows declaring fewer parameters).\n */\nexport type AsyncFn<TData, TArgs extends unknown[]> = (\n  signal: AbortSignal,\n  ...args: TArgs\n) => Promise<TData>\n\nexport interface UseAsyncOptions<TData, TArgs extends unknown[]> {\n  /**\n   * Run once on mount (with `args`). Defaults to false.\n   * **Every change** of this value triggers a run: false → true acts as a \"go\" switch.\n   */\n  immediate?: boolean\n  /**\n   * Arguments for the `immediate` run. Read once, **at the moment it fires** (kept\n   * in a ref, never in a dependency array) — so later `args` changes do not refetch;\n   * to refetch on a dependency, call `run(...)` explicitly from your own effect.\n   */\n  args?: TArgs\n  /** Fires only for the call that actually wrote state (not for stale ones). */\n  onSuccess?: (data: TData) => void\n  /** Same; `error` is always an Error instance — non-Error throws get wrapped. */\n  onError?: (error: Error) => void\n}\n\nexport interface UseAsyncResult<TData, TArgs extends unknown[]> {\n  /** The most recent successful result; null in the other three states. */\n  data: TData | null\n  /** The most recent failure; null in the other three states. */\n  error: Error | null\n  status: AsyncStatus\n  isPending: boolean\n  isSuccess: boolean\n  isError: boolean\n  /** Start a call. Stable identity, never rejects, resolves to an `AsyncOutcome`. */\n  run: (...args: TArgs) => Promise<AsyncOutcome<TData>>\n  /** Cancel the in-flight call (abort + invalidate) and go back to idle. Stable identity. */\n  reset: () => void\n}\n\nfunction toError(cause: unknown): Error {\n  if (cause instanceof Error) return cause\n  if (typeof cause === \"string\") return new Error(cause)\n  return new Error(\"The async function rejected with a non-Error value.\")\n}\n\n/**\n * The state machine for **one** async call: idle → pending → success | error,\n * plus race, cancellation and unmount safety. It is deliberately **not** a cache\n * layer — no keys, no caching, no automatic retry, no revalidation; reach for\n * TanStack Query if you want those. This is the minimum that gets the loading /\n * error booleans out of your component.\n *\n * - **Race: only the last call wins.** Each `run()` bumps an internal requestId;\n *   if the id changed by the time the `await` returns (a later call, or a\n *   `reset()`), the result is dropped — no state, no callbacks — and the call\n *   site gets `{ status: \"stale\" }`. A slow old request can never overwrite a\n *   fast new one.\n * - **Cancellation.** Each `run()` creates a fresh `AbortController` and aborts\n *   the previous one, passing the signal as `asyncFn`'s first argument; `reset()`\n *   and unmount abort too. An `asyncFn` that ignores the signal is fine —\n *   correctness comes from the requestId; abort only saves pointless network work.\n * - **Unmount safety.** `mountedRef` is set to true **in the effect body** and\n *   false in cleanup — setting it false only in cleanup leaves the live instance\n *   at false under StrictMode's mount→cleanup→mount, and pending would never\n *   clear. Every `await` re-checks it, so a late resolve never calls setState on\n *   an unmounted component.\n * - **latest-ref.** `asyncFn` / `onSuccess` / `onError` all live in refs updated\n *   every render and stay out of every dependency array: that is what stops\n *   `immediate` from re-firing each render when the caller passes inline arrow\n *   functions (they almost always do).\n * - **No setState in an effect body.** With `immediate`, the initial status is\n *   already `pending`, so the synchronous part of the mount effect's `run()` only\n *   hits the \"already pending\" short circuit; every real setState happens after\n *   an `await` (across an async boundary, which is allowed).\n */\nexport function useAsync<TData, TArgs extends unknown[] = []>(\n  asyncFn: AsyncFn<TData, TArgs>,\n  options: UseAsyncOptions<TData, TArgs> = {},\n): UseAsyncResult<TData, TArgs> {\n  const { immediate = false } = options\n\n  // all three fields update together — no torn \"success but data is still null\" frame.\n  const [state, setState] = React.useState<{\n    data: TData | null\n    error: Error | null\n    status: AsyncStatus\n  }>(() => ({\n    data: null,\n    error: null,\n    // with immediate the first frame is already pending, so the mount effect's run()\n    // does no synchronous setState in the effect body (react-hooks/set-state-in-effect).\n    status: immediate ? \"pending\" : \"idle\",\n  }))\n\n  const asyncFnRef = React.useRef(asyncFn)\n  const onSuccessRef = React.useRef(options.onSuccess)\n  const onErrorRef = React.useRef(options.onError)\n  const argsRef = React.useRef(options.args)\n  // must be declared before the mounted / immediate effects: effects run in\n  // declaration order, so the refs hold this render's closures before anything fires.\n  React.useEffect(() => {\n    asyncFnRef.current = asyncFn\n    onSuccessRef.current = options.onSuccess\n    onErrorRef.current = options.onError\n    argsRef.current = options.args\n  })\n\n  const mountedRef = React.useRef(false)\n  const requestIdRef = React.useRef(0)\n  const controllerRef = React.useRef<AbortController | null>(null)\n\n  React.useEffect(() => {\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n      // invalidate the in-flight call: once the id moves, a late resolve has no seat.\n      requestIdRef.current += 1\n      controllerRef.current?.abort()\n      controllerRef.current = null\n    }\n  }, [])\n\n  const run = React.useCallback(async (...args: TArgs): Promise<AsyncOutcome<TData>> => {\n    // a new call kills the old one first, then registers its own id.\n    controllerRef.current?.abort()\n    const controller = new AbortController()\n    controllerRef.current = controller\n    const requestId = requestIdRef.current + 1\n    requestIdRef.current = requestId\n\n    setState(prev =>\n      prev.status === \"pending\" ? prev : { data: null, error: null, status: \"pending\" },\n    )\n\n    try {\n      const data = await asyncFnRef.current(controller.signal, ...args)\n      if (requestIdRef.current !== requestId || !mountedRef.current) {\n        return { status: \"stale\" }\n      }\n      setState({ data, error: null, status: \"success\" })\n      onSuccessRef.current?.(data)\n      return { status: \"success\", data }\n    } catch (cause) {\n      // superseded/cancelled calls usually end as AbortError — check the id before the\n      // error type, so abort noise is never written to state as a real failure.\n      if (requestIdRef.current !== requestId || !mountedRef.current) {\n        return { status: \"stale\" }\n      }\n      const error = toError(cause)\n      setState({ data: null, error, status: \"error\" })\n      onErrorRef.current?.(error)\n      return { status: \"error\", error }\n    } finally {\n      if (controllerRef.current === controller) controllerRef.current = null\n    }\n  }, [])\n\n  const reset = React.useCallback(() => {\n    requestIdRef.current += 1\n    controllerRef.current?.abort()\n    controllerRef.current = null\n    setState({ data: null, error: null, status: \"idle\" })\n  }, [])\n\n  React.useEffect(() => {\n    if (!immediate) return\n    let cancelled = false\n    // fire from a microtask: (1) the effect body then contains no synchronous setState\n    // (react-hooks/set-state-in-effect); (2) under StrictMode's mount→cleanup→mount the\n    // first pass is already cancelled by its cleanup, so only the surviving one requests.\n    queueMicrotask(() => {\n      if (cancelled) return\n      void run(...((argsRef.current ?? []) as TArgs))\n    })\n    return () => {\n      cancelled = true\n    }\n  }, [immediate, run])\n\n  return {\n    data: state.data,\n    error: state.error,\n    status: state.status,\n    isPending: state.status === \"pending\",\n    isSuccess: state.status === \"success\",\n    isError: state.status === \"error\",\n    run,\n    reset,\n  }\n}\n\nexport default useAsync\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}