Skip to content

useAsyncAction

What this solves

Async UI actions (submit, save, delete, export) need consistent state handling:

  • Disable controls while running
  • Show errors and allow retry
  • Avoid duplicated "loading/error" state across components
  • Handle success/error/settled side effects without stale closures

Ad-hoc useState solutions often drift into inconsistent behavior (different error handling, forgotten resets, duplicated retry logic). useAsyncAction provides a small, predictable state machine for a single async function.


When to use it

Use useAsyncAction for client-side async flows where you want local control over:

  • Submitting a form
  • Running a one-off request (export, generate report)
  • Triggering an imperative side effect (save draft, send email)
  • Handling retry and reset locally

When NOT to use it

Do not use useAsyncAction for server state that needs caching, invalidation, background refetching, or deduplication.

  • If you’re interacting with backend resources that should be cached or shared across screens, prefer React Query (useQuery / useMutation).
  • If you need request cancellation, caching, or invalidation semantics, this hook is intentionally too small.

Basic usage

Wrap an async function and use isLoading/error to drive UI.

tsx
import { useAsyncAction } from '@/shared/hooks'

async function saveName(name: string) {
  // await api.user.update({ name })
  return { ok: true as const }
}

export function Example() {
  const save = useAsyncAction(async (name: string) => saveName(name))

  return (
    <div>
      <button
        type="button"
        onClick={() => save.run('Alice')}
        disabled={save.isLoading}
      >
        {save.isLoading ? 'Saving…' : 'Save'}
      </button>

      {save.error && (
        <p role="alert">{String(save.error instanceof Error ? save.error.message : save.error)}</p>
      )}
    </div>
  )
}

Common patterns

Pattern 1 — Submit / save with explicit reset

Use reset() when you want to clear UI state (e.g., dismiss an error banner) without re-mounting.

tsx
import { useAsyncAction } from '@/shared/hooks'

export function SavePanel() {
  const save = useAsyncAction(
    async () => {
      // await api.settings.save()
      return true
    },
    {
      onSuccess: () => {
        // show toast, close modal, etc.
      },
    }
  )

  return (
    <div>
      <button type="button" onClick={() => save.run()} disabled={save.isLoading}>
        {save.isLoading ? 'Saving…' : 'Save'}
      </button>

      {save.error && (
        <div>
          <p role="alert">Save failed.</p>
          <button type="button" onClick={save.reset}>Dismiss</button>
        </div>
      )}
    </div>
  )
}

Why this is recommended:

  • Keeps UI state local and explicit
  • Avoids leaking loading/error state into unrelated components

Pattern 2 — Retry the last attempt

retry() reruns the last run(...args) call with the same arguments.

tsx
import { useAsyncAction } from '@/shared/hooks'

export function RetryableExport() {
  const exportAction = useAsyncAction(async (format: 'csv' | 'xlsx') => {
    // await api.export.run({ format })
    return { ok: true as const }
  })

  return (
    <div>
      <button type="button" onClick={() => exportAction.run('csv')} disabled={exportAction.isLoading}>
        Export CSV
      </button>

      {exportAction.error && (
        <div>
          <p role="alert">Export failed.</p>
          <button type="button" onClick={() => exportAction.retry()} disabled={exportAction.isLoading}>
            Retry
          </button>
        </div>
      )}
    </div>
  )
}

Things to avoid

  • Treating this as a replacement for React Query when you need caching/invalidation
  • Reusing one useAsyncAction instance for unrelated operations (create one per async concern)
  • Putting domain branching logic inside the hook callback when it’s actually page/action-layer logic


API reference (concise)

ts
export interface UseAsyncActionOptions<TData = unknown> {
  onSuccess?: (data: TData) => void
  onError?: (error: Error) => void
  onSettled?: () => void
  initialData?: TData | null
}

export interface UseAsyncActionReturn<TArgs extends unknown[], TData = unknown, TError = Error> {
  run: (...args: TArgs) => Promise<TData | undefined>
  isLoading: boolean
  error: TError | null
  data: TData | null
  hasRun: boolean
  reset: () => void
  retry: () => Promise<TData | undefined>
}

export function useAsyncAction<TArgs extends unknown[], TData = unknown, TError = Error>(
  asyncFn: (...args: TArgs) => Promise<TData>,
  options?: UseAsyncActionOptions<TData>
): UseAsyncActionReturn<TArgs, TData, TError>

Final notes

  • retry() only works after at least one call to run(...) (it reuses the last arguments).
  • If you need cancellation/abort semantics, model that explicitly in your asyncFn (e.g., AbortController) and UI.