Stateful Button Component for shadcn/ui Async Operations

Add async feedback to shadcn/ui buttons with loading, progress, success, and error states. XState-powered component with full accessibility support.

Stateful Button is a shadcn/ui component that manages visual feedback for asynchronous operations in your applications.

It extends the standard shadcn/ui Button component to manage loading, progress, success, and error states.

Features

âš¡ State Machine Architecture: XState powers predictable transitions between idle, loading, success, and error states.

🔄 Dual Display Modes: Switch between spinner mode for indeterminate operations and progress mode with controlled 0-100 value tracking.

♿ Accessibility First: ARIA live regions and customizable screen reader messages for all state transitions.

🎯 Promise Integration: Async/await compatible onClick handlers with automatic state management based on resolution or rejection.

Use Cases

  • Form Submissions: Provide feedback when a user submits a form that communicates with a backend service.
  • E-commerce Checkouts: Show a loading state during payment processing and then a success or error message.
  • File Uploads: Display a progress bar to indicate the upload status of large files.
  • Data Processing: Indicate that a complex data processing task is running in the background.

How to Use It

1. Add the StatefulButton to your project with the shadcn/ui CLI. This command adds stateful-button.tsx to your components/ui directory. It also adds stateful-button-machine.ts to your lib directory.

pnpm dlx shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json
npx shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json
yarn shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json
bunx --bun shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json

2. The default mode shows a spinner while an asynchronous onClick handler executes. This mode is suitable for actions where progress is indeterminate.

import { StatefulButton } from '@/components/ui/stateful-button';

// Example of a generic API call.
// Replace these with your own fetch, axios, trpc, etc.
const fetchData = async () => {
const response = await fetch('/api/endpoint');
if (!response.ok) throw new Error('Request failed');
return response.json();
};

export default function StatefulButtonDemo() {
return (
<StatefulButton
onClick={async () => {
// Trigger the API call
const data = await fetchData();
// Here you could update state, trigger notifications, or handle data
console.log('Received data:', data);
}}
/* Called when onClick completes successfully */
onComplete={() => console.log('Operation completed successfully')}
/* Called if the API call or onClick throws an error */
onError={(error) => console.error('An error occurred:', error)}
>
Load
</StatefulButton>
);
}

3. You can use progress mode to display a progress bar for operations with trackable advancement. You must set the buttonType prop to progress and provide a controlled value between 0 and 100 to the progress prop.

import { useState } from 'react';

import { StatefulButton } from '@/components/ui/stateful-button';

async function processMultiApiRequests(setProgress: (p: number) => void) {
const totalApis = 3;
let completed = 0;

// Example API calls. Replace these with your own fetch, axios, trpc, etc.
// You can run them in parallel as shown here or sequentially depending on your workflow.
const apiCalls = [
async () => {
await fetch('/api/endpoint1');
},
async () => {
await fetch('/api/endpoint2');
},
async () => {
await fetch('/api/endpoint3');
}
];

// Run all APIs in parallel and update progress as each one finishes
await Promise.all(
apiCalls.map(async (call) => {
await call();
completed++;
setProgress(Math.floor((completed / totalApis) * 100));
})
);

// Ensure progress ends at 100
setProgress(100);
}

export function StatefulButtonProgressDemo() {
const [progress, setProgress] = useState(0);

return (
<StatefulButton
buttonType="progress"
/* Controlled progress value from 0 to 100 */
progress={progress}
/* Triggered when the button is clicked to start async logic */
onClick={() => processMultiApiRequests(setProgress)}
/* Called when all async work is done successfully */
onComplete={() => console.log('All APIs completed')}
/* Called if any error occurs during async logic */
onError={(error) => console.error(error)}
>
Run APIs
</StatefulButton>
);
}

4. The StatefulButton component accepts all standard HTML button attributes and shadcn/ui button variants. Below are its specific props.

PropTypeDefaultDescription
onClick(event) => void | Promise<unknown>A click handler invoked when the button is pressed. It can return a Promise for asynchronous actions.
onComplete() => voidA callback triggered when the action completes successfully.
onError(error: Error) => voidA callback triggered if onClick throws an error or a promise rejects.
buttonType'spinner' | 'progress''spinner'Specifies the button’s behavior mode. 'spinner' shows a loading spinner, while 'progress' displays a progress bar.
progressnumberThe current progress value from 0 to 100. This is a controlled prop for the progress bar.
childrenReact.ReactNodeThe content to render inside the button when it is in the idle state.
ariaMessagesAriaMessagesEnglish defaultsCustomizable ARIA messages for accessibility across different states.
variantstring'default'The visual style of the button, inherited from shadcn/ui’s Button component.
sizestring'default'The size of the button, inherited from shadcn/ui’s Button component.

Related Resources

  • shadcn/ui: The component library this button is designed to work with.
  • XState: The state management library used to power the button’s state transitions.
  • Radix UI: The underlying unstyled component library that shadcn/ui is built upon.

FAQs

Q: How do I prevent multiple clicks during loading states?
A: The component automatically disables the button during loading, progress, success, and error states. You don’t need additional click prevention logic.

Q: Can I customize the success and error state durations?
A: Yes, edit the state machine in lib/stateful-button-machine.ts. The default transitions reset to idle after 2 seconds, but you can modify the delay values or add custom transition logic.

Q: Does the progress prop need to reach exactly 100?
A: No, the component transitions to success when the onClick Promise resolves, regardless of the progress value. Set progress to 100 before resolution for best user experience.

Q: Can I use this with server actions in Next.js?
A: Yes, pass your server action directly to onClick. The component handles both client-side async functions and server actions that return Promises.

Fabio Somaglia

Fabio Somaglia

Software engineer

Leave a Reply

Your email address will not be published. Required fields are marked *