HextaUI

useButtonFeedback

非同期のアクションを、読み込み中、成功、エラーの順に実行します。高速なリクエストではスピナーをスキップし、エラーは読み終えるまで保持します。

    "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

    フックと、その依存関係をプロジェクトに追加します。

    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> は、onClick が promise を返すと、このフローを自動で実行します。フォームの onSubmit、キーボードショートカット、blur など、処理が別の場所で始まる場合にはこのフックを使います。ステータスがボタンではないものに属する場合にも使えます。

    track() は promise、または promise を返す関数を受け取り、status を idle、loading、そして success または error と進め、idle に戻します。タイミングが、落ち着いた印象を生みます。

    ステップ説明
    0–150msステータスは idle のままです。この間に完了したリクエストは、スピナーなしで直接 success または error になります。
    loading150msから表示されます。表示されたら最低400msは続くので、一瞬だけ表示されることはありません。
    successデフォルトでは2秒間保持され、その後 idle に戻ります。
    errorデフォルトでは4秒間保持されます。ポインターがボタン上にある間、またはキーボードフォーカスがある間は、それらが離れてから600ms後までリセットを待ちます。
    • リクエストの実行中に track() を呼んでも無視されるため、ダブルクリックや Enter キーの押しっぱなしでリクエストが2回送信されることはありません。
    • track() に渡した関数が同期的に例外を投げた場合は、reject された promise と同じように扱われます。
    • reset() はすぐに idle に戻ります。破棄されたリクエストがその後に行うことや、コンポーネントのアンマウント後に完了するものは無視されます。
    • エラーの保持は、実際のマウスのホバーとキーボードフォーカスだけを数えます。タッチにはホバーがなく、クリックによるフォーカスは :focus-visible ではないため、どちらもエラーを固定しません。

    フォーム

    onSubmit から track() を呼び出し、送信ボタンに buttonProps を展開します。エラーを確認するには @ を取り除いてください。

    "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 を読んで任意の UI を駆動します。このメモはフォーカスを失うと保存され、スクリーンリーダーが読み上げる role="status" の領域に、隣に結果を表示します。

    "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 は両方の結果に対して1つの数値、または個別に設定するオブジェクトを受け取ります。error は最後の reject の理由を保持するので、Button のエラー詳細の例のように、ラベルに表示できます。

    • 各ボタンに専用のフックを用意してください。1つの buttonProps を2つのボタンで共有すると、どちらも同じステータスを表示します。
    • onStatusChange と onError は常に最後に渡した関数を呼ぶため、インライン関数でも問題ありません。
    • track() の外側の処理を保護するには、isPending() を使います。ref を読むため、次のレンダリング前でも正確です。
    プロパティ型デフォルト
    resetAfter成功とエラーが idle に戻るまで残る時間。
    number | { success?: number; error?: number }{ success: 2000, error: 4000 }
    onStatusChangeステータスが変わるたびに呼ばれます。
    (status: ButtonStatus) => void–
    onError拒否の理由とともに呼ばれます。
    (error: unknown) => void–
    プロパティ説明
    track(action)promise、または promise を返す関数を渡します。リクエストの実行中は無視されます。
    buttonPropsstatus に加え、ポインターとフォーカスのハンドラー。<Button>、またはそれらのハンドラーを合成するものに展開します。
    status"idle" | "loading" | "success" | "error"
    error直近の拒否の理由。
    reset()すぐに idle に戻り、実行中のリクエストを無視します。
    isPending()リクエストが実行中かどうか。

    feedback prop を通じた Button。