HextaUI

useButtonFeedback

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

    "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>
      )
    }
    pnpm dlx shadcn@latest add https://hextaui.com/r/use-button-feedback.json

    Adds the hook and anything it depends on to your project.

    import { useButtonFeedback } from "@/hooks/use-button-feedback"
    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.

    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.

    StepDescription
    0–150msStatus stays idle. A request that settles in this window goes straight to success or error, without a spinner.
    loadingShown from 150ms. Once shown it lasts at least 400ms, so it never flashes.
    successHeld for 2 seconds by default, then returns to idle.
    errorHeld 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.

    "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("[email protected]")
    
      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.

    "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>
      )
    }
    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 example does.

    • 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.
    PropTypeDefault
    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–
    PropertyDescription
    track(action)Pass a promise or a function returning one. Ignored while a request is in flight.
    buttonPropsstatus plus pointer and focus handlers. Spread on <Button>, or on anything that composes those handlers.
    status"idle" | "loading" | "success" | "error"
    errorThe 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.