# Async Action

> Run a request from a button, show the result at once, and keep its recovery available — retry after a failure, cancel while pending, and undo when your application can reverse it.

Source: https://www.honestui.com/docs/product/async-action

```tsx
"use client"

import * as React from "react"

import {
  ActionButton,
  ActionStatus,
  useAsyncAction,
} from "@/registry/default/product/async-action/async-action"

type Project = { id: string; name: string }

// Sample projects.
const sampleProjects: Project[] = [
  { id: "p1", name: "Docs refresh" },
  { id: "p2", name: "Billing migration" },
  { id: "p3", name: "Onboarding emails" },
]

function wait(milliseconds: number) {
  return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
}

/**
 * There is no server behind this preview; both requests are timers. The hook
 * lives above the list, so its state outlives the row that Archive removes.
 */
export function AsyncActionDemo() {
  const [projects, setProjects] = React.useState(sampleProjects)

  const archive = useAsyncAction({
    action: async (project: Project) => {
      await wait(900)
      return project
    },
    optimistic: (project) => {
      setProjects((current) => current.filter((item) => item.id !== project.id))
      // Put the project back where it was, whatever else changed meanwhile.
      return () =>
        setProjects((current) =>
          sampleProjects.filter(
            (item) => item.id === project.id || current.some((kept) => kept.id === item.id)
          )
        )
    },
    undo: () => wait(600),
  })

  const name = archive.state.phase === "idle" ? "" : archive.state.input.name

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col gap-[var(--hui-space-4)]">
      {projects.length === 0 ? (
        <p className="m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]">
          No active projects.
        </p>
      ) : (
        <ul
          aria-label="Active projects"
          className="m-0 list-none rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-secondary)] p-0 [&>*+*]:border-t-[0.5px] [&>*+*]:border-t-[var(--hui-color-border-base-secondary)]"
        >
          {projects.map((project) => (
            <li
              key={project.id}
              className="flex min-w-0 items-center justify-between gap-[var(--hui-space-4)] px-[var(--hui-space-4)] py-[var(--hui-space-3)]"
            >
              <span className="min-w-0 break-words text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]">
                {project.name}
              </span>
              <ActionButton
                action={archive}
                input={project}
                variant="secondary"
                size="sm"
                aria-label={`Archive ${project.name}`}
              >
                Archive
              </ActionButton>
            </li>
          ))}
        </ul>
      )}
      <ActionStatus
        action={archive}
        labels={{
          pending: `Archiving ${name}…`,
          success: `${name} archived.`,
          undoing: `Restoring ${name}…`,
          undone: `${name} restored.`,
        }}
      />
    </div>
  )
}

```

## Overview [#overview]

Async Action coordinates one request and what happens after it. `useAsyncAction` runs the request and holds its state, `ActionButton` starts it, and `ActionStatus` says what is happening and offers the recovery that applies.

Take "Archive project". The project leaves the list as soon as the button is pressed. If the request fails, the project returns and the status offers Retry. If it succeeds and you passed a reversal, the status offers Undo.

A [promise toast](/docs/components/toast) reports the same phases, but the report ends when the toast closes. Here the state belongs to the hook, so Retry and Undo stay available until someone dismisses the status or runs the action again.

Async Action does not send requests, store data, or know how to reverse anything. You pass the request, the optimistic update, and the reversal. It offers only the recovery you supplied.

## Anatomy [#anatomy]

<Anatomy title="AsyncAction">
  <AnatomyItem name="useAsyncAction" description="Runs the request and holds the phase, input, result, and error" />

  <AnatomyItem name="ActionButton" description="Starts a run; shows a spinner while its own run is pending" />

  <AnatomyItem name="ActionStatus" description="One row beside or below the control">
    <AnatomyItem name="Message" description="The current phase in words, in a polite status region" />

    <AnatomyItem name="Cancel" description="While pending, and only when the action is declared cancelable" />

    <AnatomyItem name="Retry" description="After the request fails, or after an undo fails" />

    <AnatomyItem name="Undo" description="After success, and only when a reversal was passed" />

    <AnatomyItem name="Dismiss" description="Once the action has settled" />
  </AnatomyItem>
</Anatomy>

The three pieces are independent. Use the hook alone with any control, such as a switch or a menu item, and render `ActionStatus` wherever the outcome should be read.

Installing `async-action` brings `Button` along as a registry dependency.

## Behavior [#behavior]

### Phases [#phases]

An action is in one phase at a time: `idle`, `pending`, `success`, `error`, `cancelled`, `undoing`, `undone`, or `undoError`. `action.state` carries the phase and what is known in it: the input from `pending` onward, the result after success, and the error after a failure.

### One run at a time [#one-run-at-a-time]

`run` does nothing while a request or an undo is in flight, and resolves to `{ status: "ignored" }`. This holds for two clicks in the same instant, because the state is kept outside React and changes before the next render. `ActionButton` stays focusable while it is inactive, so a keyboard user does not lose their place.

One hook serves one action at a time. When several rows share a hook, every `ActionButton` is inactive during a run and only the one that started it shows a spinner.

### Optimistic updates [#optimistic-updates]

`optimistic` receives the input, changes your state to the expected result, and returns a function that takes the change back. The hook calls that rollback when the request fails, when the run is cancelled, and after an undo succeeds. Write the rollback as a state update based on the current value, so it stays correct if other things changed in the meantime.

Call the hook above anything the update removes. If a row owned the hook, archiving the row would unmount the state along with it.

### Retry [#retry]

After a failure, `retry` runs the request again with the same input and applies the optimistic update again. After a failed undo, it repeats the undo.

### Cancel [#cancel]

Cancel is offered only when you pass `cancelable`. It aborts the `signal` given to your action, rolls back the optimistic update, and moves to `cancelled`. If the cancelled request settles later anyway, its result is discarded, including after a newer run has started.

Pass `cancelable` only when aborting the signal really stops the work. If the server may have finished already, a "Cancelled" message would be untrue.

### Undo [#undo]

Undo is offered only when you pass `undo`, a function that reverses the action on the server. It receives the input and the result. When it resolves, the hook calls the optimistic rollback to restore the interface. When it rejects, the action still stands, the interface is left as it is, and the status offers Retry.

Success and its Undo stay until the status is dismissed or the action runs again. Nothing expires on a timer.

### Leaving the page [#leaving-the-page]

A request that is in flight when the component unmounts is not aborted. The rollback and the `onSuccess` and `onError` callbacks still run, so keep state they touch somewhere that outlives the component.

## Accessibility [#accessibility]

`ActionStatus` is a polite status region, so each message is announced when it changes. Render it from the start and keep it mounted: it is empty while the action is idle, and screen readers announce only regions that were already on the page. The recovery buttons sit outside the region so they are not read as part of the message.

Each phase has a sentence. The spinner, check, and alert icons are decorative, and nothing depends on color.

While a run is in flight, `ActionButton` sets `aria-disabled` and ignores presses instead of becoming `disabled`, which would drop keyboard focus.

A phase change can remove the control that has focus: a Retry button that was just pressed, or a row the optimistic update took away. When that happens, `ActionStatus` takes focus itself, so the next <kbd>Tab</kbd> continues from the status instead of the top of the page.

Give each `ActionButton` in a list a name that includes its object, such as `aria-label="Archive Docs refresh"`.

| Key                                  | Action                                                                  |
| ------------------------------------ | ----------------------------------------------------------------------- |
| <kbd>Tab</kbd>                       | Move to the action button, then across Cancel, Retry, Undo, and Dismiss |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Activate the focused button                                             |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;async-action&#x22;]" />
  

  
    
      
        No extra packages are needed beyond the base setup.
      

      
        Copy and paste these files into your project.
      

      <ComponentSource name="async-action" title="components/ui/async-action/async-action-types.ts" file="async-action-types.ts" />

      <ComponentSource name="async-action" title="components/ui/async-action/async-action-utils.ts" file="async-action-utils.ts" />

      <ComponentSource name="async-action" title="components/ui/async-action/use-async-action.ts" file="use-async-action.ts" />

      <ComponentSource name="async-action" title="components/ui/async-action/async-action.tsx" file="async-action.tsx" />

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

Call the hook where the list's state lives, then pass the action to a button in each row and to one status:

```tsx
import {
  ActionButton,
  ActionStatus,
  useAsyncAction,
} from "@/components/ui/async-action/async-action"

const archive = useAsyncAction({
  action: (project: Project, { signal }) => api.archiveProject(project.id, { signal }),
  optimistic: (project) => {
    setProjects((current) => current.filter((item) => item.id !== project.id))
    return () => setProjects((current) => [...current, project])
  },
  undo: (project) => api.restoreProject(project.id),
})

{projects.map((project) => (
  <ActionButton key={project.id} action={archive} input={project} aria-label={`Archive ${project.name}`}>
    Archive
  </ActionButton>
))}

<ActionStatus action={archive} />
```

An action that takes no input needs no `input` prop:

```tsx
const exportReport = useAsyncAction({ action: (_input: void, { signal }) => api.exportReport({ signal }) })

<ActionButton action={exportReport} pendingLabel="Exporting…">Export report</ActionButton>
```

### Messages [#messages]

The default messages are generic, such as "Done." and "This did not finish. Try again." Replace them with sentences about your action. `error` and `undoError` can be functions of the error.

```tsx
const name = archive.state.phase === "idle" ? "" : archive.state.input.name

<ActionStatus
  action={archive}
  labels={{
    pending: `Archiving ${name}…`,
    success: `${name} archived.`,
    error: (error) =>
      isOffline(error)
        ? `You are offline, so ${name} is back in the list.`
        : `The request failed, so ${name} is back in the list.`,
  }}
/>
```

Every word the status writes comes from `labels`, including the button names, so the same prop translates it.

### With a toast [#with-a-toast]

To show a toast as well, call `toastManager` from `onSuccess` or `onError`. The toast can close on its own schedule, and Retry and Undo remain in the status.

```tsx
useAsyncAction({
  action: archiveProject,
  onError: () => toastManager.add({ type: "error", title: "Project not archived" }),
})
```

### Without the button [#without-the-button]

`run` returns a promise that resolves to how the run ended and never rejects, so any handler can start the action:

```tsx
<Switch checked={isSubscribed} readOnly={save.isBusy} onCheckedChange={(next) => void save.run(next)} />
```

## Don't do this [#dont-do-this]

### Offering an undo that does not undo [#offering-an-undo-that-does-not-undo]

```tsx
// Bad. The project is put back on screen, but it is still archived on the server.
undo: async () => setProjects((current) => [...current, project])
```

```tsx
// Good. The server reverses the action; the hook then restores the list with the rollback.
undo: (project) => api.restoreProject(project.id)
```

Leave `undo` out when the action cannot be reversed. No Undo is better than one that misleads.

### Calling the hook inside the row [#calling-the-hook-inside-the-row]

```tsx
// Bad. Archiving removes the row, and the action's state is unmounted with it.
function ProjectRow({ project }) {
  const archive = useAsyncAction({ action: () => api.archiveProject(project.id), optimistic: removeRow })
}
```

Call the hook in the list and pass the project as `input`, as in Usage.

### Mounting the status only when there is something to say [#mounting-the-status-only-when-there-is-something-to-say]

```tsx
// Bad. The region appears together with its first message, so the message is not announced.
{archive.state.phase !== "idle" && <ActionStatus action={archive} />}
```

Render `ActionStatus` unconditionally. It takes up no space while the action is idle.

## Examples [#examples]

### Failure and retry [#failure-and-retry]

This preview rejects the first attempt. The project returns to the list with Retry, and the second attempt succeeds. No reversal is passed, so success offers no Undo.

```tsx
"use client"

import * as React from "react"

import {
  ActionButton,
  ActionStatus,
  useAsyncAction,
} from "@/registry/default/product/async-action/async-action"

type Project = { id: string; name: string }

// Sample projects.
const sampleProjects: Project[] = [
  { id: "p4", name: "Q3 roadmap" },
  { id: "p5", name: "Support macros" },
]

function wait(milliseconds: number) {
  return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
}

/**
 * There is no server behind this preview. The first attempt is rejected on
 * purpose to show the failure state; the next attempt resolves. No `undo` is
 * passed, so success offers no Undo.
 */
export function AsyncActionFailureExample() {
  const [projects, setProjects] = React.useState(sampleProjects)
  const attempts = React.useRef(0)

  const archive = useAsyncAction<Project>({
    action: async () => {
      attempts.current += 1
      await wait(700)
      if (attempts.current === 1) {
        throw new Error("This preview rejects the first attempt.")
      }
    },
    optimistic: (project) => {
      setProjects((current) => current.filter((item) => item.id !== project.id))
      return () =>
        setProjects((current) =>
          sampleProjects.filter(
            (item) => item.id === project.id || current.some((kept) => kept.id === item.id)
          )
        )
    },
  })

  const name = archive.state.phase === "idle" ? "" : archive.state.input.name

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col gap-[var(--hui-space-4)]">
      {projects.length === 0 ? (
        <p className="m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]">
          No active projects.
        </p>
      ) : (
        <ul
          aria-label="Projects to archive"
          className="m-0 list-none rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-secondary)] p-0 [&>*+*]:border-t-[0.5px] [&>*+*]:border-t-[var(--hui-color-border-base-secondary)]"
        >
          {projects.map((project) => (
            <li
              key={project.id}
              className="flex min-w-0 items-center justify-between gap-[var(--hui-space-4)] px-[var(--hui-space-4)] py-[var(--hui-space-3)]"
            >
              <span className="min-w-0 break-words text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]">
                {project.name}
              </span>
              <ActionButton
                action={archive}
                input={project}
                variant="secondary"
                size="sm"
                aria-label={`Archive ${project.name}`}
              >
                Archive
              </ActionButton>
            </li>
          ))}
        </ul>
      )}
      <ActionStatus
        action={archive}
        labels={{
          pending: `Archiving ${name}…`,
          success: `${name} archived.`,
          error: `The request failed, so ${name} is back in the list.`,
        }}
      />
    </div>
  )
}

```

### Cancel and repeated clicks [#cancel-and-repeated-clicks]

A cancelable action with no optimistic update. The count shows how many requests began, however many times the button is pressed.

```tsx
"use client"

import * as React from "react"

import {
  ActionButton,
  ActionStatus,
  useAsyncAction,
} from "@/registry/default/product/async-action/async-action"

/** A timer that stops when the signal aborts, as a fetch given the same signal would. */
function waitUnlessAborted(milliseconds: number, signal: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    const timer = window.setTimeout(resolve, milliseconds)
    signal.addEventListener("abort", () => {
      window.clearTimeout(timer)
      reject(signal.reason)
    })
  })
}

/**
 * There is no server behind this preview and no file is created. The count
 * shows how many requests began, so repeated clicks can be seen to start one.
 */
export function AsyncActionCancelExample() {
  const [requestsStarted, setRequestsStarted] = React.useState(0)

  const exportReport = useAsyncAction({
    action: (_input: void, { signal }) => {
      setRequestsStarted((count) => count + 1)
      return waitUnlessAborted(4000, signal)
    },
    cancelable: true,
  })

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col items-start gap-[var(--hui-space-4)]">
      <ActionButton action={exportReport} pendingLabel="Exporting…">
        Export report
      </ActionButton>
      <ActionStatus
        action={exportReport}
        labels={{
          pending: "Preparing the export…",
          success: "Export finished. This preview does not create a file.",
          cancelled: "Export cancelled.",
        }}
      />
      <p className="m-0 text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]">
        Requests started: {requestsStarted}
      </p>
    </div>
  )
}

```

### The hook with another control [#the-hook-with-another-control]

A switch that saves on change. This preview rejects the first change, so the switch returns to its saved position.

```tsx
"use client"

import * as React from "react"

import {
  ActionStatus,
  useAsyncAction,
} from "@/registry/default/product/async-action/async-action"
import { Switch } from "@/components/honest-ui/ui/switch"

function wait(milliseconds: number) {
  return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
}

/**
 * The hook without ActionButton. There is no server behind this preview: the
 * first change is rejected on purpose, so the switch can be seen returning to
 * its saved position. Later changes resolve.
 */
export function AsyncActionHookExample() {
  const [isSubscribed, setIsSubscribed] = React.useState(false)
  const attempts = React.useRef(0)
  const labelId = React.useId()

  const save = useAsyncAction<boolean>({
    action: async () => {
      attempts.current += 1
      await wait(700)
      if (attempts.current === 1) {
        throw new Error("This preview rejects the first change.")
      }
    },
    optimistic: (next) => {
      setIsSubscribed(next)
      return () => setIsSubscribed(!next)
    },
  })

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col gap-[var(--hui-space-3)]">
      <div className="inline-flex min-h-6 items-center gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]">
        <Switch
          aria-labelledby={labelId}
          checked={isSubscribed}
          readOnly={save.isBusy}
          onCheckedChange={(next) => void save.run(next)}
          // Forced colors removes the track and thumb fills, so draw both with system colors.
          className="forced-colors:box-content forced-colors:border forced-colors:border-[ButtonText] forced-colors:[&>span]:bg-[ButtonText]"
        />
        <span id={labelId}>Email me a weekly summary</span>
      </div>
      <ActionStatus
        action={save}
        labels={{
          pending: "Saving…",
          success: "Saved.",
          error: "Not saved. The setting is back to what it was.",
        }}
      />
    </div>
  )
}

```

## API reference [#api-reference]

### useAsyncAction [#useasyncaction]

`useAsyncAction<TInput, TResult>(options)` returns the action. `TInput` is inferred from the first parameter of `action`.

| Option       | Type                                      | Default  | Description                                                                     |
| ------------ | ----------------------------------------- | -------- | ------------------------------------------------------------------------------- |
| `action`     | `(input, { signal }) => Promise<TResult>` | Required | The request. `signal` aborts when the run is cancelled.                         |
| `optimistic` | `(input) => (() => void) \| void`         | None     | Applies the expected result and returns its rollback.                           |
| `undo`       | `(input, { result }) => Promise<unknown>` | None     | Reverses the action. Passing it offers Undo after success.                      |
| `cancelable` | boolean                                   | `false`  | Offers Cancel while pending. Set only when aborting `signal` stops the request. |
| `onSuccess`  | `(result, input) => void`                 | None     | Runs after the request resolves.                                                |
| `onError`    | `(error, input) => void`                  | None     | Runs after the request rejects and the rollback has run.                        |

Options are read when they are needed, so they can be new functions on every render.

### The action [#the-action]

| Property     | Type                                              | Description                                                              |
| ------------ | ------------------------------------------------- | ------------------------------------------------------------------------ |
| `state`      | `AsyncActionState<TInput, TResult>`               | The phase, with `input`, `result`, and `error` when they are known.      |
| `isBusy`     | boolean                                           | A request or an undo is in flight.                                       |
| `canCancel`  | boolean                                           | Pending, and the action is cancelable.                                   |
| `canRetry`   | boolean                                           | The request or the undo failed.                                          |
| `canUndo`    | boolean                                           | Succeeded, and a reversal was passed.                                    |
| `canDismiss` | boolean                                           | Settled, so `reset` will clear it.                                       |
| `run`        | `(input) => Promise<AsyncActionOutcome<TResult>>` | Starts a run. Resolves to `success`, `error`, `cancelled`, or `ignored`. |
| `retry`      | `() => void`                                      | Repeats the failed request or the failed undo.                           |
| `cancel`     | `() => void`                                      | Aborts a pending, cancelable run and rolls back.                         |
| `undo`       | `() => void`                                      | Runs the reversal after success.                                         |
| `reset`      | `() => void`                                      | Returns a settled action to idle. Does nothing while busy.               |

### ActionButton [#actionbutton]

Accepts every `Button` prop beyond those listed.

| Prop           | Type                            | Default  | Description                                               |
| -------------- | ------------------------------- | -------- | --------------------------------------------------------- |
| `action`       | The value from `useAsyncAction` | Required | The action to run on press.                               |
| `input`        | `TInput`                        | None     | Passed to `run`. Required when the action takes an input. |
| `pendingLabel` | ReactNode                       | None     | Replaces the children while this button's run is pending. |

A press handler that calls `event.preventDefault()` stops the run.

### ActionStatus [#actionstatus]

Accepts native div props beyond those listed.

| Prop     | Type                            | Default  | Description                                             |
| -------- | ------------------------------- | -------- | ------------------------------------------------------- |
| `action` | The value from `useAsyncAction` | Required | The action to report.                                   |
| `labels` | `Partial<ActionStatusLabels>`   | English  | Messages for each phase and names for the four buttons. |

### Types [#types]

| Export               | Shape                                                                                                                |
| -------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `AsyncActionState`   | `{ phase: "idle" }`, or a phase with `input`, plus `result` after success and `error` after a failure                |
| `AsyncActionOutcome` | `{ status: "success"; result }`, `{ status: "error"; error }`, `{ status: "cancelled" }`, or `{ status: "ignored" }` |
| `ActionStatusLabels` | `pending`, `success`, `error`, `cancelled`, `undoing`, `undone`, `undoError`, `retry`, `cancel`, `undo`, `dismiss`   |
