Async Button
A button that performs async operations with a no-layout-shift loading state.
import { AsyncButton } from "@/components/ui/async-button"";import { toast } from "sonner";
const asyncAction = async () => { await new Promise((resolve) => setTimeout(resolve, 1000));};
export function BasicAsyncButton() { return ( <AsyncButton action={asyncAction} loadingText="loading" onSuccess={() => toast.success("Action Complete")} > Do Action </AsyncButton> );}Installation
Section titled “Installation”Install the following dependencies
Copy and paste the following code into your project.
components/ui/async-button.tsx
import { Button } from "@/components/ui/button";import { useAsync, type UseAsyncOptions,} from "@/hooks/use-async";import { type ComponentProps, type ReactNode } from "react";import { Loader2 } from "lucide-react";import { cn } from "@/lib/utils";
export type AsyncButtonProps< TData, TError, TArgs extends unknown[] = [],> = Omit<ComponentProps<typeof Button>, "onClick"> & UseAsyncOptions<TData, TError, TArgs> & { /** Content displayed while the action is in flight. Defaults to `children` when omitted. */ loadingText?: ReactNode; };
/** * A `Button` that manages its own async state. * * Disables itself while the action is in flight and swaps children for a spinner * (+ optional `loadingText`) via an invisible overlay — the button width never shifts. * * **Required:** `action` */export function AsyncButton<TData, TError, TArgs extends unknown[] = []>({ action, loadingText, onSuccess, onError, onSettled, children, disabled, className, ...props}: AsyncButtonProps<TData, TError, TArgs>) { const { isLoading, execute } = useAsync({ action, onSuccess, onError, onSettled, });
const resolvedLoadingText = loadingText ?? children;
return ( <Button {...props} disabled={disabled || isLoading} onClick={() => (execute as () => Promise<TData>)()} className={cn("grid grid-cols-1 place-items-center", className)} > <div className={cn( "col-start-1 row-start-1 flex items-center justify-center gap-2 transition-all", isLoading ? "invisible opacity-0" : "visible opacity-100", )} > {children} </div>
<div className={cn( "col-start-1 row-start-1 flex items-center justify-center gap-2", isLoading ? "visible opacity-100" : "invisible opacity-0", )} > <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> {resolvedLoadingText && <span>{resolvedLoadingText}</span>} </div> </Button> );}Update the import paths to match your project setup.
import { AsyncButton } from "@/components/ui/async-button";import { addToCart } from "@/actions/cart";import { toast } from "sonner";
<AsyncButton action={() => addToCart({ inCart: true })} onSuccess={() => toast.success("Added to cart!")} onError={() => toast.error("Something went wrong.")}> Add to Cart</AsyncButton>;Examples
Section titled “Examples”Destructive With Error
Section titled “Destructive With Error”import { AsyncButton } from "@/components/ui/async-button"";import { toast } from "sonner";
const asyncAction = async () => { await new Promise((_, reject) => { setTimeout(() => reject("Something wrong happened!"), 1000); });};
export function AsyncButtonWithError() { return ( <AsyncButton<unknown, string> action={asyncAction} variant={"destructive"} onError={(error) => { toast.error(`Rejected : ${error}`); }} > Should Failed </AsyncButton> );}| Prop | Type | Default | Description |
|---|---|---|---|
action | () => Promise<TData> | TData | — | The async function to run on click |
loadingText | ReactNode | children | Content shown inside the button while loading |
onSuccess | (data?: TData) => void | — | Called after action resolves |
onError | (error?: TError) => void | — | Called if action throws |
onSettled | () => void | — | Called after either success or error |
All other Button props are forwarded.