HextaUI

useButtonFeedback

Ejecuta una acción asíncrona pasando por carga, éxito y error, omitiendo el spinner en peticiones rápidas y manteniendo un error mientras lo lees.

    "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

    Añade el hook y todo lo que necesita a tu proyecto.

    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> ejecuta este flujo por ti cuando su onClick devuelve una promesa. Usa el hook cuando el trabajo empieza en otro sitio, como el onSubmit de un formulario, un atajo de teclado o un blur. También sirve cuando el estado pertenece a algo que no es un botón.

    track() acepta una promesa, o una función que devuelva una, y mueve status por idle, loading, luego success o error, y de vuelta a idle. Los tiempos son lo que lo hace sentir tranquilo.

    PasoDescripción
    0–150msEl estado permanece en idle. Una petición que se resuelve en esta ventana pasa directamente a success o error, sin spinner.
    loadingSe muestra a partir de 150ms. Una vez mostrado dura al menos 400ms, así que nunca aparece de golpe.
    successSe mantiene 2 segundos por defecto y luego vuelve a idle.
    errorSe mantiene 4 segundos por defecto. Mientras el puntero está sobre el botón, o este tiene el foco del teclado, el reinicio espera a que se vayan, más 600ms.
    • Las llamadas a track() mientras hay una petición en curso se ignoran, así que un doble clic o una tecla Enter mantenida nunca envían la petición dos veces.
    • Una función pasada a track() que lanza un error de forma síncrona se trata como una promesa rechazada.
    • reset() vuelve a idle de inmediato. Lo que haga más tarde la petición abandonada se ignora, igual que cualquier cosa que se resuelva después de desmontar el componente.
    • La retención del error solo cuenta un hover real del ratón y el foco del teclado. El táctil no tiene hover, y el foco de un clic no es :focus-visible, así que ninguno de los dos fija el error.

    Formularios

    Llama a track() desde onSubmit y expande buttonProps en el botón de envío. Quita la @ para ver el 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>
      )
    }

    Estado sin un botón

    Lee status para controlar cualquier UI. Esta nota se guarda al perder el foco y muestra el resultado a su lado, en una región role="status" que los lectores de pantalla anuncian.

    "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 acepta un solo número para ambos resultados, o un objeto para definir cada uno. error contiene el último motivo de rechazo, para que puedas mostrarlo en la etiqueta, como hace el ejemplo de detalles de error de Button.

    • Dale a cada botón su propio hook. Dos botones que compartan un buttonProps muestran el mismo estado.
    • onStatusChange y onError siempre llaman a la última función que pasaste, así que las funciones en línea están bien.
    • Usa isPending() para proteger trabajo fuera de track(). Lee un ref, así que es preciso incluso antes del siguiente renderizado.
    PropTipoPredeterminado
    resetAfterCuánto tiempo permanecen success y error antes de volver a idle.
    number | { success?: number; error?: number }{ success: 2000, error: 4000 }
    onStatusChangeSe llama en cada cambio de estado.
    (status: ButtonStatus) => void–
    onErrorSe llama con el motivo del rechazo.
    (error: unknown) => void–
    PropiedadDescripción
    track(action)Pasa una promesa o una función que devuelva una. Se ignora mientras hay una petición en curso.
    buttonPropsstatus más manejadores de puntero y foco. Expándelo en <Button>, o en cualquier cosa que componga esos manejadores.
    status"idle" | "loading" | "success" | "error"
    errorEl último motivo de rechazo.
    reset()Vuelve a idle ahora e ignora la petición en curso.
    isPending()Si hay una petición en curso.

    Button mediante su prop feedback.