useButtonFeedback
Runs an async action through loading, success and error, skipping the spinner for fast requests and holding an error while you read it.
pnpm dlx shadcn@latest add https://hextaui.com/r/use-button-feedback.jsonAdds the hook and anything it depends on to your project.
Copy and paste the following code into your project.
hooks/use-button-feedback.ts Update the import paths to match your project setup.
<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.
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.
Forms
Call track() from onSubmit and spread buttonProps on the submit button. Remove the @ to see the error.
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.
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 example does.
- Give each button its own hook. Two buttons sharing one
buttonPropsboth show the same status. onStatusChangeandonErroralways call the latest function you passed, so inline functions are fine.- Use
isPending()to guard work outsidetrack(). It reads a ref, so it's accurate even before the next render.
| Prop | Type | Default |
|---|---|---|
resetAfterHow long success and error stay before returning to idle. | number | { success?: number; error?: number } | { success: 2000, error: 4000 } |
onStatusChangeCalled on every status change. | (status: ButtonStatus) => void | – |
onErrorCalled with the rejection reason. | (error: unknown) => void | – |
| 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. |
Button through its feedback prop.