Skip to contentVibraUI
Feedback & status

Async button

A button that runs its own loading state off whatever its onClick returns.

A client component. Return a promise from onClick and the button disables itself, shows a spinner, sets aria-busy, and re-enables when the promise settles — including when it rejects, which is the caller's to report. While it works it is disabled but stays focusable (aria-disabled, through Button's focusableWhenDisabled), so a keyboard user keeps the focus they pressed it with; Button dims it all the same, and a second press cannot start the work twice. It sets aria-disabled itself whenever it is disabled but focusable — while loading, or when you pass disabled with focusableWhenDisabled — because the attribute a caller passes, undefined or unset, lands after Base UI's own and would wipe it, and the button would look pressable; otherwise your aria-disabled stands. A disabled prop of your own stays native unless you ask for focusableWhenDisabled. State is dropped if the button unmounts mid-flight. Everything else is a Button prop.

Install

npx shadcn@latest add @vibra/async-button

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
onClick(event: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>—Return a promise and the button holds its loading state until that promise settles.
loadingbooleanfalseForces the loading state, for work this button did not start itself.
loadingTextReact.ReactNodethe childrenReplaces the label while loading; the label stays put when it is unset.
disabledbooleanfalseDisables the button on top of whatever the loading state does.

Dependencies

Source

components/ui/async-button.tsx
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"

function isPromise(value: unknown): value is Promise<unknown> {
  return typeof (value as Promise<unknown> | undefined)?.then === "function"
}

export type AsyncButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick"> & {
  /** Forces the loading state, for work this button did not start itself. */
  loading?: boolean
  /** Replaces the label while loading; the label stays put when it is unset. */
  loadingText?: React.ReactNode
  /** Return a promise and the button holds its loading state until that promise settles. */
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>
}

/**
 * A <Button> that runs its own loading state off whatever `onClick` returns —
 * no `useState` in the page for the one thing every submit button needs.
 */
function AsyncButton({
  className,
  children,
  loading = false,
  loadingText,
  onClick,
  disabled,
  focusableWhenDisabled,
  "aria-disabled": ariaDisabled,
  ...props
}: AsyncButtonProps) {
  const [pending, setPending] = React.useState(false)
  const mountedRef = React.useRef(true)

  React.useEffect(() => {
    mountedRef.current = true
    return () => {
      mountedRef.current = false
    }
  }, [])

  const isLoading = loading || pending
  const isDisabled = Boolean(disabled) || isLoading
  const staysFocusable = focusableWhenDisabled ?? isLoading

  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
    const result = onClick?.(event)
    if (!isPromise(result)) return

    setPending(true)
    const settle = () => {
      if (mountedRef.current) setPending(false)
    }
    // Both outcomes only free the button. A rejection is the caller's to report —
    // catch it inside `onClick` and raise a notify.error from there.
    result.then(settle, settle)
  }

  return (
    <Button
      data-slot="async-button"
      data-loading={isLoading || undefined}
      aria-busy={isLoading || undefined}
      // While it works the button is disabled but stays focusable: a natively
      // disabled button drops the focus it was pressed with, and a keyboard
      // user starts over from the top of the page. Base UI still swallows a
      // second press, and Button dims an aria-disabled button like a disabled
      // one. A caller's own disabled stays native unless it asks otherwise.
      disabled={isDisabled}
      focusableWhenDisabled={staysFocusable}
      // Set here rather than left to Base UI, because the attribute a caller
      // passes — undefined when it computes one and the button is enabled, or
      // simply unset — lands after Base UI's own and overwrites it: a button
      // disabled but kept focusable, mid-request or by the caller, looked
      // pressable. Any such button says so; otherwise the caller's stands.
      aria-disabled={isDisabled && staysFocusable ? true : ariaDisabled}
      onClick={handleClick}
      className={className}
      {...props}
    >
      {/* aria-hidden, not a second live region: aria-busy on the button already says it is working. */}
      {isLoading ? <Spinner aria-hidden="true" /> : null}
      {isLoading ? (loadingText ?? children) : children}
    </Button>
  )
}

export { AsyncButton }