Azalea

SubmitButton & ActionButton

A control that knows it is already working.

Why they exist

Every app in the suite runs server actions, and every app has to say “this is in flight”. Ten of the twelve say it by hand.

Apps using useFormStatus
2
of twelve
Files hand-rolling it
20
useTransition + loading
Ways the two wrote it
2
and both dropped a prop

The bug that makes it a component

useFormStatus reads the status of the form the calling component is rendered inside — not the one it renders.

Called in the component that returns the <form>, it never fires

It reads the parent's form, which is usually no form at all, so pending stays false forever. The failure is silent: the button simply never shows a spinner, which reads as the action being slow rather than as a mistake. That is the whole reason this is a separate component and not a hook call at the top of your form.

And the fields lock too

The half nobody does. A form that disables its button and leaves its inputs live lets somebody edit the values that are already on their way to the server — the record then disagrees with the screen, and nothing on the page says so.

Submit it — the field locks along with the button.

ActionButton, for a page with no form

Root's replay, approve, promote, freeze and cancel; Ivy's retry; Grove's restore. All the same fifteen lines of useTransition, local state and a FormMessage. Press all three below — they are the three outcomes.

Three things the hand-rolled ones drop

Each of these is in the middle button above, and each was missing from at least half the copies.

The returned error survives. Half the copies await the action and discard what it returns, so a failed replay is indistinguishable from a successful one — the spinner stops and nothing else happens.

A thrown error is caught. useTransition does not catch. An action that rejects unmounts the page into the nearest error boundary — the whole screen replaced, for one failed retry.

confirm is a Dialog, not window.confirm. The native one cannot be styled or themed and prints the page's hostname above the question, which on a console for an internal tool reads like a phishing prompt.

Destructive, behind a question

The confirmation carries the verb from the button, never “OK” — somebody who opened it by accident reads the button, not the paragraph.

Deploy to production?

This releases 4e3e84f to all twelve services. There is no undo, only a redeploy of the previous build.

Guidance

Use it when

  • SubmitButton in every form with a server action — it costs one import and removes a hook.
  • FormFields around the inputs, so the form is inert while it submits.
  • ActionButton for a control that acts without a form: a retry, a replay, an approve.

Reach for something else when

  • useFormStatus in the component that renders the <form>. It reads the parent's status and silently never fires.
  • Discarding what the action returned. A failure that looks like a success is worse than an error message.
  • confirm for something reversible. A dialog on every button trains people to dismiss dialogs.

Props

PropTypeDefaultDescription
SubmitButton …ButtonPropsEverything Button takes. type and loading are supplied.
FormFields childrenReactNodeWrapped in a <fieldset disabled> while the form is in flight.
ActionButton action() => Promise<{ error? } | void>Return { error } to show a message. Anything thrown is caught and shown too.
ActionButton confirmstringThe question. Renders a ConfirmDialog before the action runs.
ActionButton confirmTitlestring"Are you sure?"The dialog's heading.
ActionButton successMessagestringShown beside the button after the action succeeds.

Accessibility

Both render Azalea's Button, so they inherit its aria-busy — which is what tells a screen reader the control is busy rather than simply gone — and its focus ring. ActionButton's result goes through FormMessage, which carries aria-live="polite": the outcome is announced rather than only seen, and politely, because the user just acted and is already looking there.