HextaUI

useButtonFeedback

Exécute une action asynchrone avec chargement, succès et erreur, en évitant le spinner pour les requêtes rapides et en maintenant une erreur le temps de la lire.

    "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

    Ajoute le hook et tout ce dont il dépend à votre projet.

    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> exécute ce flux pour vous quand son onClick renvoie une promesse. Utilisez le hook quand le travail démarre ailleurs, comme le onSubmit d'un formulaire, un raccourci clavier ou un blur. Il fonctionne aussi quand le statut doit s'afficher sur autre chose qu'un bouton.

    track() accepte une promesse, ou une fonction qui en renvoie une, et fait passer status par idle, loading, puis success ou error, avant de revenir à idle. C'est le timing qui donne cette impression de calme.

    ÉtapeDescription
    0–150msLe statut reste idle. Une requête qui se termine dans cette fenêtre passe directement à success ou error, sans spinner.
    loadingAffiché à partir de 150ms. Une fois affiché, il dure au moins 400ms, pour ne jamais clignoter.
    successMaintenu 2 secondes par défaut, puis retour à idle.
    errorMaintenu 4 secondes par défaut. Tant que le pointeur est sur le bouton ou qu'il a le focus clavier, la réinitialisation attend leur départ, plus 600ms.
    • Les appels à track() pendant qu'une requête est en cours sont ignorés : un double-clic ou une touche Enter maintenue n'envoie jamais la requête deux fois.
    • Une fonction passée à track() qui lève une exception de façon synchrone est traitée comme une promesse rejetée.
    • reset() revient à idle aussitôt. Tout ce que la requête abandonnée fait plus tard est ignoré, de même que tout ce qui se termine après le démontage du composant.
    • Le maintien de l'erreur ne compte que le vrai survol à la souris et le focus clavier. Le toucher n'a pas de survol, et le focus d'un clic n'est pas :focus-visible : ni l'un ni l'autre ne fige donc l'erreur.

    Formulaires

    Appelez track() depuis onSubmit et répartissez buttonProps sur le bouton d'envoi. Retirez le @ pour voir l'erreur.

    "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>
      )
    }

    Statut sans bouton

    Lisez status pour piloter n'importe quelle interface. Cette note s'enregistre quand elle perd le focus et affiche le résultat à côté, dans une région role="status" que les lecteurs d'écran annoncent.

    "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 accepte un nombre pour les deux issues, ou un objet pour régler chacune. error contient la dernière raison de rejet, que vous pouvez afficher dans le label, comme le fait l'exemple des détails d'erreur de Button.

    • Donnez à chaque bouton son propre hook. Deux boutons partageant un même buttonProps affichent tous deux le même statut.
    • onStatusChange et onError appellent toujours la dernière fonction que vous avez passée : les fonctions en ligne conviennent donc.
    • Utilisez isPending() pour protéger le travail en dehors de track(). Il lit une ref, donc il est exact même avant le rendu suivant.
    PropTypePar défaut
    resetAfterCombien de temps success et error restent avant le retour à idle.
    number | { success?: number; error?: number }{ success: 2000, error: 4000 }
    onStatusChangeAppelé à chaque changement de statut.
    (status: ButtonStatus) => void–
    onErrorAppelé avec la raison du rejet.
    (error: unknown) => void–
    PropriétéDescription
    track(action)Passez une promesse ou une fonction qui en renvoie une. Ignoré pendant qu'une requête est en cours.
    buttonPropsstatus ainsi que les gestionnaires de pointeur et de focus. À répartir sur <Button>, ou sur tout élément qui compose ces gestionnaires.
    status"idle" | "loading" | "success" | "error"
    errorLa dernière raison de rejet.
    reset()Revient à idle immédiatement et ignore la requête en cours.
    isPending()Indique si une requête est en cours.

    Button via sa prop feedback.