React Button Loading State Example
Show a spinner inside a React button during async work. Disable clicks, preserve width, and keep the label readable. Drop-in Drivn Button example.
When a button triggers an asynchronous action — a form submit, an API call, a file upload — the user needs a visible signal that the work is in progress. Done poorly, the button flickers in width as the label is replaced by a spinner, or the user double-clicks and triggers the request twice. Done well, the button disables itself, keeps its original width, and shows a spinner alongside or instead of the label.
Drivn's Button component ships a built-in loading prop that handles all three concerns in one line. It disables pointer events, renders a spinner, and preserves the button's previous width so the layout doesn't shift. There is no second component to import, no wrapper div to add, and no state library to integrate — loading is a boolean that does the right thing by default.
This page walks through the exact usage, the React state pattern that drives it, the accessibility considerations you should not skip, and how to wire the loading prop to React Hook Form so submission state stays in a single source of truth. Every snippet below is copy-paste ready and assumes Drivn is already installed via the CLI.
The loading prop
Setting loading={true} on a Drivn button adds a spinner to the leading edge, disables the button, and prevents click handlers from firing. The label remains visible so screen readers still announce the button's purpose. No extra wrapper, no useState gymnastics — just one prop bound to your async state.
The snippet below shows the canonical pattern: a local boolean saving toggled around the async call inside a try/finally block. The finally ensures the button re-enables even if fetch throws, which keeps the UI recoverable from network errors. Pair this with a toast to surface success or failure to the user.
1 'use client' 2 import * as React from 'react' 3 import { Button } from '@/components/ui/button' 4 5 export function SaveButton() { 6 const [saving, setSaving] = React.useState(false) 7 const onSave = async () => { 8 setSaving(true) 9 try { 10 await fetch('/api/save', { method: 'POST' }) 11 } finally { 12 setSaving(false) 13 } 14 } 15 return ( 16 <Button loading={saving} onClick={onSave}> 17 Save changes 18 </Button> 19 ) 20 }
Accessibility
A loading button should communicate its state to assistive tech. Drivn's Button sets aria-busy="true" while loading is true, which screen readers announce as "Save changes, busy". The button is also given disabled-like semantics so keyboard focus skips it until work completes.
Do not remove the visible label during loading — icon-only loading buttons fail WCAG 2.1 for users who need the text context. If your button shows only an icon, add an aria-label that describes the action ("Save", "Delete", etc.).
Combine with async form state
When the button lives inside a form using React Hook Form, bind loading to the form's formState.isSubmitting instead of maintaining a separate state. This keeps the source of truth in one place and automatically re-enables the button if submission fails. Because React Hook Form tracks the whole lifecycle of the submit promise, the button flips to loading the moment the handler starts and flips back when it resolves or throws.
This pattern also avoids a subtle bug: if you keep a separate saving state and forget to reset it on error, the button stays disabled and the user gets stuck. Let the form library own the state.
1 'use client' 2 import { useForm, type SubmitHandler } from 'react-hook-form' 3 import { Button } from '@/components/ui/button' 4 5 interface ContactFormData { 6 email: string 7 message: string 8 } 9 10 export function ContactForm() { 11 const { handleSubmit, formState } = useForm<ContactFormData>() 12 13 const onSubmit: SubmitHandler<ContactFormData> = async (data) => { 14 await fetch('/api/contact', { 15 method: 'POST', 16 body: JSON.stringify(data), 17 }) 18 } 19 20 return ( 21 <form onSubmit={handleSubmit(onSubmit)}> 22 <Button loading={formState.isSubmitting}>Send</Button> 23 </form> 24 ) 25 }
Install Drivn in one command
Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.
npx drivn@latest createRequires Node 18+. Works with npm, pnpm, and yarn.
Frequently asked questions
Yes. When loading is true the Drivn Button sets aria-busy="true" and ignores click events internally. You do not need to also check the state inside your onClick handler, and double-clicks during async work will not trigger the request twice.
The default spinner is a Lucide Loader2 icon rotated with a CSS animation. Because Drivn components live in your repo after install, swap it by editing src/components/ui/button.tsx and replacing the icon import. There is no prop for it by design — the goal is a single visual language across buttons.
Use a loading button for actions under ~2 seconds — the user triggered it and expects immediate feedback in place. For longer operations, show a skeleton in the affected region or a toast with progress. Full-page spinners block interaction and should be reserved for initial app load or route transitions.
Yes. The Button emits aria-busy="true" while loading, which modern screen readers announce. The label remains readable so context is preserved. If your button is icon-only, also add an aria-label describing the action — spinners alone do not convey purpose to assistive tech.