Appearance
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
useAsyncActioninstance for unrelated operations (create one per async concern) - Putting domain branching logic inside the hook callback when it’s actually page/action-layer logic
Related primitives
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 torun(...)(it reuses the last arguments).- If you need cancellation/abort semantics, model that explicitly in your
asyncFn(e.g.,AbortController) and UI.