Skip to content
Drivn logoDrivn
3 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.

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'
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 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'
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.