HextaUI

useButtonFeedback

Führt eine asynchrone Aktion durch Laden, Erfolg und Fehler, überspringt den Spinner bei schnellen Anfragen und hält einen Fehler, während du ihn liest.

    "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

    Fügt den Hook und alles, wovon er abhängt, zu deinem Projekt hinzu.

    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> führt diesen Ablauf für dich aus, wenn sein onClick ein Promise zurückgibt. Nutze den Hook, wenn die Arbeit woanders beginnt, etwa im onSubmit eines Formulars, einem Tastenkürzel oder einem Blur. Er funktioniert auch, wenn der Status zu etwas gehört, das kein Button ist.

    track() nimmt ein Promise oder eine Funktion, die eines zurückgibt, und führt status durch idle, loading, dann success oder error und zurück zu idle. Das Timing macht es ruhig.

    SchrittBeschreibung
    0–150msDer Status bleibt idle. Eine Anfrage, die in diesem Fenster endet, geht direkt zu success oder error, ohne Spinner.
    loadingAb 150ms angezeigt. Einmal angezeigt, hält er mindestens 400ms, sodass er nie aufblitzt.
    successStandardmäßig 2 Sekunden gehalten, dann zurück zu idle.
    errorStandardmäßig 4 Sekunden gehalten. Solange der Zeiger über dem Button ist oder er Tastaturfokus hat, wartet das Zurücksetzen, bis beides endet, plus 600ms.
    • Aufrufe von track() während eine Anfrage läuft, werden ignoriert, sodass ein Doppelklick oder eine gehaltene Enter-Taste die Anfrage nie zweimal sendet.
    • Eine an track() übergebene Funktion, die synchron wirft, wird wie ein abgelehntes Promise behandelt.
    • reset() geht sofort zu idle zurück. Was die verworfene Anfrage später tut, wird ignoriert, ebenso alles, was nach dem Unmount der Komponente endet.
    • Das Halten des Fehlers zählt nur echtes Maus-Hovern und Tastaturfokus. Touch hat kein Hover, und der Fokus nach einem Klick ist nicht :focus-visible, sodass keines von beiden den Fehler festhält.

    Formulare

    Rufe track() aus onSubmit auf und spreade buttonProps auf den Submit-Button. Entferne das @, um den Fehler zu sehen.

    "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 ohne Button

    Lies status, um beliebige UI zu steuern. Diese Notiz speichert, wenn sie den Fokus verliert, und zeigt das Ergebnis daneben, in einem role="status"-Bereich, den Screenreader ansagen.

    "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 nimmt eine Zahl für beide Ausgänge oder ein Objekt, um jeden einzeln zu setzen. error hält den letzten Ablehnungsgrund, sodass du ihn im Label zeigen kannst, wie es das Beispiel Fehlerdetails des Button tut.

    • Gib jedem Button seinen eigenen Hook. Zwei Buttons, die sich ein buttonProps teilen, zeigen beide denselben Status.
    • onStatusChange und onError rufen immer die zuletzt übergebene Funktion auf, daher sind Inline-Funktionen in Ordnung.
    • Nutze isPending(), um Arbeit außerhalb von track() abzusichern. Es liest eine Ref und ist daher auch vor dem nächsten Render korrekt.
    PropTypStandard
    resetAfterWie lange success und error stehen bleiben, bevor es zu idle zurückgeht.
    number | { success?: number; error?: number }{ success: 2000, error: 4000 }
    onStatusChangeWird bei jeder Statusänderung aufgerufen.
    (status: ButtonStatus) => void–
    onErrorWird mit dem Ablehnungsgrund aufgerufen.
    (error: unknown) => void–
    PropertyBeschreibung
    track(action)Übergib ein Promise oder eine Funktion, die eines zurückgibt. Wird ignoriert, während eine Anfrage läuft.
    buttonPropsstatus sowie Pointer- und Fokus-Handler. Spreade sie auf <Button> oder auf alles, was diese Handler zusammensetzt.
    status"idle" | "loading" | "success" | "error"
    errorDer letzte Ablehnungsgrund.
    reset()Geht sofort zu idle zurück und ignoriert die laufende Anfrage.
    isPending()Ob eine Anfrage läuft.

    Button über seine feedback-Prop.