# useButtonFeedback

> Runs an async action through loading, success and error, skipping the spinner for fast requests and holding an error while you read it.

Docs: https://hextaui.com/docs/use-button-feedback
Markdown: https://hextaui.com/docs/use-button-feedback.md

```tsx title="components/examples/use-button-feedback/fast.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  useButtonFeedback,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

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

function RequestButton({
  ms,
  onStatus,
}: {
  ms: number
  onStatus: (entry: string) => void
}) {
  const { buttonProps, track } = useButtonFeedback({
    onStatusChange: (status: ButtonStatus) => onStatus(`${ms}ms: ${status}`),
  })

  return (
    <Button
      {...buttonProps}
      variant="outline"
      successLabel="Done"
      onClick={() => track(wait(ms))}
    >
      {ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`} request
    </Button>
  )
}

export function UseButtonFeedbackFast() {
  const [log, setLog] = React.useState<string[]>([])
  const push = React.useCallback(
    (entry: string) => setLog((entries) => [...entries.slice(-3), entry]),
    []
  )

  return (
    <div className="flex w-full max-w-xs flex-col items-center gap-4">
      <div className="flex gap-2">
        <RequestButton ms={80} onStatus={push} />
        <RequestButton ms={1200} onStatus={push} />
      </div>
      <ol className="flex min-h-20 flex-col items-center gap-0.5 font-mono text-xs text-muted-foreground">
        {log.map((entry, index) => (
          <li key={index}>{entry}</li>
        ))}
      </ol>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/use-button-feedback.json
```

This adds the hook and anything it depends on.

### Manual

Copy and paste the following code into your project.

```ts title="hooks/use-button-feedback.ts"
import * as React from "react"

type ButtonStatus = "idle" | "loading" | "success" | "error"

type ButtonResetAfter = number | { success?: number; error?: number }

type ButtonFeedbackOptions = {
  resetAfter?: ButtonResetAfter
  onStatusChange?: (status: ButtonStatus) => void
  onError?: (error: unknown) => void
}

const spinnerDelay = 150
const minimumSpinnerTime = 400
const resumeResetDelay = 600
const defaultResetAfter = { success: 2000, error: 4000 }

function resolveResetAfter(
  resetAfter: ButtonResetAfter | undefined,
  outcome: "success" | "error"
) {
  if (typeof resetAfter === "number") {
    return resetAfter
  }
  return resetAfter?.[outcome] ?? defaultResetAfter[outcome]
}

function useButtonFeedback(options: ButtonFeedbackOptions = {}) {
  const [status, setStatus] = React.useState<ButtonStatus>("idle")
  const [error, setError] = React.useState<unknown>(undefined)
  const optionsRef = React.useRef(options)
  const runRef = React.useRef(0)
  const inFlightRef = React.useRef(false)
  const timersRef = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const holdRef = React.useRef({ hovered: false, focused: false })
  const resetPendingRef = React.useRef(false)
  const statusRef = React.useRef<ButtonStatus>("idle")

  React.useLayoutEffect(() => {
    optionsRef.current = options
  })

  const clearTimers = React.useCallback(() => {
    timersRef.current.forEach(clearTimeout)
    timersRef.current = []
  }, [])

  React.useEffect(
    () => () => {
      runRef.current += 1
      clearTimers()
    },
    [clearTimers]
  )

  const update = React.useCallback((next: ButtonStatus) => {
    statusRef.current = next
    setStatus(next)
    optionsRef.current.onStatusChange?.(next)
  }, [])

  const later = React.useCallback((callback: () => void, delay: number) => {
    const run = runRef.current
    timersRef.current.push(
      setTimeout(() => {
        if (run === runRef.current) {
          callback()
        }
      }, delay)
    )
  }, [])

  const isHeld = React.useCallback(
    () =>
      statusRef.current === "error" &&
      (holdRef.current.hovered || holdRef.current.focused),
    []
  )

  const resume = React.useCallback(() => {
    if (resetPendingRef.current && !isHeld()) {
      resetPendingRef.current = false
      later(() => update("idle"), resumeResetDelay)
    }
  }, [isHeld, later, update])

  const track = React.useCallback(
    (action: PromiseLike<unknown> | (() => PromiseLike<unknown>)) => {
      if (inFlightRef.current) {
        return
      }
      const run = ++runRef.current
      let spinnerShownAt: number | null = null

      inFlightRef.current = true
      resetPendingRef.current = false
      clearTimers()

      later(() => {
        spinnerShownAt = Date.now()
        update("loading")
      }, spinnerDelay)

      const settle = (outcome: "success" | "error", reason?: unknown) => {
        if (run !== runRef.current) {
          return
        }
        clearTimers()
        const remaining =
          spinnerShownAt === null
            ? 0
            : Math.max(0, minimumSpinnerTime - (Date.now() - spinnerShownAt))

        later(() => {
          inFlightRef.current = false
          if (outcome === "error") {
            setError(reason)
            optionsRef.current.onError?.(reason)
          }
          update(outcome)
          later(
            () => {
              if (isHeld()) {
                resetPendingRef.current = true
              } else {
                update("idle")
              }
            },
            resolveResetAfter(optionsRef.current.resetAfter, outcome)
          )
        }, remaining)
      }

      try {
        const promise = typeof action === "function" ? action() : action
        Promise.resolve(promise).then(
          () => settle("success"),
          (reason: unknown) => settle("error", reason)
        )
      } catch (reason) {
        settle("error", reason)
      }
    },
    [clearTimers, isHeld, later, update]
  )

  const reset = React.useCallback(() => {
    runRef.current += 1
    clearTimers()
    inFlightRef.current = false
    resetPendingRef.current = false
    update("idle")
  }, [clearTimers, update])

  const handlers = React.useMemo(
    () => ({
      onPointerEnter: (event: React.PointerEvent<HTMLElement>) => {
        if (event.pointerType === "mouse") {
          holdRef.current.hovered = true
        }
      },
      onPointerLeave: () => {
        holdRef.current.hovered = false
        resume()
      },
      onFocus: (event: React.FocusEvent<HTMLElement>) => {
        holdRef.current.focused = event.currentTarget.matches(":focus-visible")
      },
      onBlur: () => {
        holdRef.current.focused = false
        resume()
      },
    }),
    [resume]
  )

  return {
    status,
    error,
    track,
    reset,
    isPending: () => inFlightRef.current,
    buttonProps: { status, ...handlers },
  }
}

export { useButtonFeedback }
export type { ButtonFeedbackOptions, ButtonResetAfter, ButtonStatus }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { useButtonFeedback } from "@/hooks/use-button-feedback"
```

```tsx
const { buttonProps, track } = useButtonFeedback()

<form onSubmit={(event) => {
  event.preventDefault()
  track(() => saveProfile(new FormData(event.currentTarget)))
}}>
  …
  <Button type="submit" {...buttonProps}>Save</Button>
</form>
```

`<Button feedback>` runs this flow for you when its `onClick` returns a promise. Use the hook when the work starts somewhere else, like a form's `onSubmit`, a keyboard shortcut or a blur. It also works when the status belongs on something that isn't a button.

## How it works

`track()` takes a promise, or a function that returns one, and moves `status` through `idle`, `loading`, then `success` or `error`, and back to `idle`. The timing is what makes it feel calm.

| Step | Description |
| --- | --- |
| `0–150ms` | Status stays idle. A request that settles in this window goes straight to success or error, without a spinner. |
| `loading` | Shown from 150ms. Once shown it lasts at least 400ms, so it never flashes. |
| `success` | Held for 2 seconds by default, then returns to idle. |
| `error` | Held for 4 seconds by default. While the pointer is over the button, or it has keyboard focus, the reset waits until they leave, plus 600ms. |

- Calls to `track()` while a request is in flight are ignored, so a double click or a held Enter key never sends the request twice.
- A function passed to `track()` that throws synchronously is treated like a rejected promise.
- `reset()` returns to idle at once. Whatever the abandoned request does later is ignored, and so is anything that settles after the component unmounts.
- The error hold only counts a real mouse hover and keyboard focus. Touch has no hover, and a click's focus isn't `:focus-visible`, so neither one pins the error.

## Examples

### Forms

Call `track()` from `onSubmit` and spread `buttonProps` on the submit button. Remove the @ to see the error.

```tsx title="components/examples/button/form.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { useButtonFeedback } from "@/hooks/use-button-feedback"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

async function fail(ms: number) {
  await wait(ms)
  throw new Error("Invalid email")
}

export function ButtonForm() {
  const save = useButtonFeedback()
  const [email, setEmail] = React.useState("olivia@example.com")

  return (
    <form
      className="flex w-full max-w-sm items-center gap-2"
      onSubmit={(event) => {
        event.preventDefault()
        save.track(email.includes("@") ? wait(900) : fail(600))
      }}
    >
      <input
        aria-label="Email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
        className="h-9 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:text-touch"
      />
      <Button
        type="submit"
        {...save.buttonProps}
        successLabel="Subscribed"
        errorLabel="Invalid email"
      >
        Subscribe
      </Button>
    </form>
  )
}
```

### Status without a button

Read `status` to drive any UI. This note saves when it loses focus and shows the result beside it, in a `role="status"` region that screen readers announce.

```tsx title="components/examples/use-button-feedback/autosave.tsx"
"use client"

import * as React from "react"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"

import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
import { useButtonFeedback } from "@/hooks/use-button-feedback"

function save(fail: boolean) {
  return new Promise<void>((resolve, reject) =>
    setTimeout(
      () => (fail ? reject(new Error("Network error")) : resolve()),
      900
    )
  )
}

const labels = {
  idle: "",
  loading: "Saving…",
  success: "Saved",
  error: "Couldn’t save",
}

export function UseButtonFeedbackAutosave() {
  const [fail, setFail] = React.useState(false)
  const { status, track } = useButtonFeedback({ resetAfter: 1500 })

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <textarea
        aria-label="Notes"
        rows={4}
        defaultValue="Edit me, then click outside to save."
        onBlur={() => track(() => save(fail))}
        className="w-full resize-none rounded-lg bg-muted px-3 py-2 text-sm/6 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:text-touch"
      />
      <div className="flex items-center justify-between gap-4 text-sm">
        <label className="flex items-center gap-2 text-muted-foreground">
          <Switch checked={fail} onCheckedChange={setFail} size="sm" />
          Fail the save
        </label>
        <span
          role="status"
          className="flex h-5 items-center gap-1.5 text-muted-foreground data-[status=error]:text-destructive"
          data-status={status}
        >
          {status === "loading" ? <Spinner size="sm" /> : null}
          {status === "success" ? (
            <IconCircleCheck className="size-3.5 text-success" />
          ) : null}
          {status === "error" ? <IconAlertCircle className="size-3.5" /> : null}
          {labels[status]}
        </span>
      </div>
    </div>
  )
}
```

### Timing and errors

```tsx
const { status, error, track, reset } = useButtonFeedback({
  resetAfter: { success: 1500, error: 6000 },
  onError: (error) => reportError(error),
})
```

`resetAfter` takes one number for both outcomes, or an object to set each one. `error` holds the last rejection reason, so you can show it in the label, as [Button's error details](https://hextaui.com/docs/button#error-details) example does.

## Good to know

- Give each button its own hook. Two buttons sharing one `buttonProps` both show the same status.
- `onStatusChange` and `onError` always call the latest function you passed, so inline functions are fine.
- Use `isPending()` to guard work outside `track()`. It reads a ref, so it's accurate even before the next render.

## API reference

### useButtonFeedback(options?)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `resetAfter` | `number \| { success?: number; error?: number }` | `{ success: 2000, error: 4000 }` | How long success and error stay before returning to idle. |
| `onStatusChange` | `(status: ButtonStatus) => void` | – | Called on every status change. |
| `onError` | `(error: unknown) => void` | – | Called with the rejection reason. |

### Returns

| Property | Description |
| --- | --- |
| `track(action)` | Pass a promise or a function returning one. Ignored while a request is in flight. |
| `buttonProps` | status plus pointer and focus handlers. Spread on <Button>, or on anything that composes those handlers. |
| `status` | "idle" \| "loading" \| "success" \| "error" |
| `error` | The last rejection reason. |
| `reset()` | Returns to idle now and ignores the request in flight. |
| `isPending()` | Whether a request is in flight. |

### Used by

`Button` through its `feedback` prop.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
