HextaUI

useButtonFeedback

Executa uma ação assíncrona passando por carregamento, sucesso e erro, pulando o spinner em requisições rápidas e mantendo o erro na tela enquanto você o lê.

    "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

    Adiciona o hook e tudo de que ele depende ao seu projeto.

    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> executa este fluxo para você quando seu onClick retorna uma promise. Use o hook quando o trabalho começa em outro lugar, como o onSubmit de um formulário, um atalho de teclado ou um blur. Também funciona quando o status pertence a algo que não é um botão.

    track() recebe uma promise, ou uma função que retorna uma, e move status por idle, loading, depois success ou error, e de volta a idle. O tempo é o que o faz parecer calmo.

    EtapaDescrição
    0–150msO status permanece idle. Uma requisição que termina nesta janela vai direto para success ou error, sem spinner.
    loadingExibido a partir de 150ms. Depois de exibido, dura pelo menos 400ms, para nunca piscar.
    successMantido por 2 segundos por padrão, depois volta a idle.
    errorMantido por 4 segundos por padrão. Enquanto o ponteiro está sobre o botão, ou ele tem foco do teclado, o reset espera até saírem, mais 600ms.
    • Chamadas a track() enquanto uma requisição está em andamento são ignoradas, então um clique duplo ou a tecla Enter segurada nunca envia a requisição duas vezes.
    • Uma função passada a track() que lança erro de forma síncrona é tratada como uma promise rejeitada.
    • reset() volta a idle de imediato. O que a requisição abandonada fizer depois é ignorado, assim como qualquer coisa que termine depois da desmontagem do componente.
    • A retenção do erro só conta hover real do mouse e foco do teclado. O toque não tem hover, e o foco de um clique não é :focus-visible, então nenhum dos dois mantém o erro.

    Formulários

    Chame track() em onSubmit e espalhe buttonProps no botão de envio. Remova o @ para ver o erro.

    "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 sem botão

    Leia status para controlar qualquer UI. Esta nota salva quando perde o foco e mostra o resultado ao lado, em uma região role="status" que os leitores de tela anunciam.

    "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 aceita um número para os dois resultados, ou um objeto para definir cada um. error guarda o último motivo da rejeição, para você mostrá-lo no rótulo, como faz o exemplo de detalhes do erro do Button.

    • Dê a cada botão seu próprio hook. Dois botões compartilhando o mesmo buttonProps mostram o mesmo status.
    • onStatusChange e onError sempre chamam a função mais recente que você passou, então funções inline funcionam bem.
    • Use isPending() para proteger trabalho fora de track(). Ele lê uma ref, então é preciso mesmo antes da próxima renderização.
    PropTipoPadrão
    resetAfterQuanto tempo success e error permanecem antes de voltar a idle.
    number | { success?: number; error?: number }{ success: 2000, error: 4000 }
    onStatusChangeChamado a cada mudança de status.
    (status: ButtonStatus) => void–
    onErrorChamado com o motivo da rejeição.
    (error: unknown) => void–
    PropriedadeDescrição
    track(action)Passe uma promise ou uma função que retorne uma. Ignorado enquanto uma requisição está em andamento.
    buttonPropsstatus mais handlers de ponteiro e foco. Espalhe em <Button>, ou em qualquer coisa que componha esses handlers.
    status"idle" | "loading" | "success" | "error"
    errorO último motivo de rejeição.
    reset()Volta a idle agora e ignora a requisição em andamento.
    isPending()Se há uma requisição em andamento.

    Button por meio da sua prop feedback.