Skip to content
Drivn
4 min read

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.

An asynchronous button — one that submits a form, calls an API, or kicks off an upload — has to tell the user that something is happening the instant it is clicked. When it doesn't, one of two things goes wrong: the button jumps in width as its label is swapped for a spinner, or the user, seeing no feedback, clicks again and fires the request twice. The fix is a button that disables itself on click, holds its original width, and shows a spinner without losing its label.

Drivn's Button folds all three behaviors into a single loading prop. Setting it true renders a spinner, blocks pointer events, and preserves the width the button had a moment earlier so nothing around it reflows. There is no second component to import, no wrapper div, and no state machine to stand up — loading is a boolean that does the correct thing on its own.

Below you will find the canonical usage, the small useState pattern that drives it, the accessibility details you should not skip, and how to hand the whole thing to React Hook Form so submission state lives in one place. Every snippet is copy-paste ready and assumes Drivn is already installed through 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'
2import * as React from 'react'
3import { Button } from '@/components/ui/button'
4
5export 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}

Preserving button width

The spinner in Drivn's Button is rendered inline with the label, so width is determined by the longest of (spinner + label) and (label alone). In practice this means the button never narrows when loading — the spinner takes the place of any leading icon you had set via leftIcon. If you were not using leftIcon, the spinner adds a few pixels of leading space; the trailing area and the label are untouched.

Avoid swapping the label to "Saving…" and back — it causes re-layout and is unnecessary now that the spinner carries the signal. Keep the verb ("Save", "Delete", "Sign in") stable.

Accessibility

A loading button has to report its state to assistive technology, not just paint a spinner. While loading is true, Drivn's Button sets aria-busy="true", which modern screen readers surface as "Save changes, busy" — the label plus the state, read together. The button also takes on disabled-like semantics, so keyboard focus moves past it until the async work finishes and it becomes actionable again.

The one rule not to break: keep the visible label during loading. An icon-only button that shows nothing but a spinner fails WCAG 2.1 for anyone who depends on the text to know what the control does — a bare spinner communicates "busy" but never "busy doing what". If your design is genuinely icon-only, add an aria-label that names the action ("Save", "Delete", "Sign in") so the purpose survives even while the spinner is up.

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'
2import { useForm, type SubmitHandler } from 'react-hook-form'
3import { Button } from '@/components/ui/button'
4
5interface ContactFormData {
6 email: string
7 message: string
8}
9
10export 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}
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

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.