Async button
Button that runs an abortable action(signal) promise, owns pending loading state, and surfaces completion/error on the custom element path. Same native button semantics as Button, with loading derived from the operation (not a controlled loading prop).
Usage
import { AsyncButton } from "@sometic/react/button";
export function Example() {
return (
<AsyncButton
action={async (signal) => {
await fetch("/api/save", { signal });
}}
>
Save
</AsyncButton>
);
}import { AsyncButton } from "@sometic/react/button";
export function Example(): JSX.Element {
return (
<AsyncButton
action={async (signal) => {
await fetch("/api/save", { signal });
}}
>
Save
</AsyncButton>
);
}<script type="module">
import { registerButtonElements } from "@sometic/elements/button";
registerButtonElements();
const el = document.querySelector("sometic-async-button");
el.action = async (signal) => {
await fetch("/api/save", { signal });
};
</script>
<sometic-async-button>Save</sometic-async-button>Vue
<script setup>
import { AsyncButton } from "@sometic/vue/button";
async function save(signal) {
await fetch("/api/save", { signal });
}
</script>
<template>
<AsyncButton :action="save">Save</AsyncButton>
</template>React/Vue types define
action: (signal: AbortSignal) => Promise<unknown>. Pass the abort signal as the first argument, not{ signal }.
How it works
- Engine:
createAsyncButtonControllerwraps@sometic/coreasync operation + button resolve. Pending ⇒ loading view; re-entry aborts prior work. - Adapters: React/Vue omit external
loading; derive it from the operation. Click callspress. - Custom element:
sometic-async-buttonassigns.actionas a function property; emits pending / error lifecycle events.
Anatomy
Same as Button slots; loader shows while the operation is pending.
| Part | data-slot | Role |
|---|---|---|
| Root | root | Native <button> |
| Prefix | prefix | Leading adornment |
| Content | content | Label / children |
| Suffix | suffix | Trailing adornment |
| Loader | loader | Present while pending |
Props / attributes
React AsyncButtonProps
Omit<ButtonProps, "loading"> & { action: (signal: AbortSignal) => Promise<unknown> }.
| Prop | Type | Description |
|---|---|---|
action | (signal: AbortSignal) => Promise<unknown> | Required. Abortable work |
(Button props except loading) | type, disabled, styling, slots, … |
Vue
Prop: action (required), plus button props (type, disabled, unstyled, …). Emits gated click after press settles.
Custom element (sometic-async-button)
Observed: type, disabled, size, variant, shadow. Set element.action = async (signal) => { … } (attributes cannot carry the callback).
Events / callbacks
| Surface | Event |
|---|---|
| React | Native click starts action; handle success/failure inside action |
| Vue | click after press; status via controller subscription |
| CE | pending / error-style custom events |
| Controller | subscribe for pending state; press returns a promise |
Controlled vs uncontrolled
Loading is owned by the async operation (not a controlled loading prop). disabled remains prop-driven.
Form participation
type="submit" still submits the form. Prefer relying on pending disabled press to block double-submit, or handle submit in action without a native form post. Coordinate with Form onValid when using the forms engine.
Accessibility
| Concern | Guidance |
|---|---|
| Pending | Same as Button loading (aria-busy, disabled press) |
| Keyboard | Space / Enter start the action when enabled |
| Errors | Announce failures in your UI (Alert / Toast); React does not auto-announce |
| Name | Visible label or aria-label |
Styling
[data-loading] while pending; standard button slots and size/variant attrs.
<AsyncButton
unstyled
classes={{ root: "btn", loader: "btn__spinner" }}
action={async (signal) => {
await fetch("/api/save", { signal });
}}
>
Save
</AsyncButton>Edge cases
- Rapid re-clicks: abort the previous signal and start again.
- Unmount: dispose cancels outstanding work (React/Vue dispose with the adapter).
- Rejected promises: surface on CE; in React catch inside
actionor let reject reach your error strategy. - SSR: create controller only on the client; register CE in the browser.
- Changing
action: React recreates the controller whenactionidentity changes; stabilize with a stable function when possible. - Double submit: pending forces ignored presses like Button loading.
Performance notes
One async operation controller per button. Abort keeps network work from stacking. Prefer this over manually juggling loading on Button.
When to use / When not
Use for abortable saves / deletes with automatic pending UI.
Do not use for:
- Pure sync clicks (Button)
- Navigation (use a link)
- Multi-step wizards (Form submit handlers)
FAQ
Signal shape? (signal: AbortSignal) => …, not { signal }.
Can I pass loading? Omitted from props; derived from the operation.
Abort on unmount? Dispose cancels outstanding work via the async controller.
Submit buttons? Works, but coordinate with Form onValid if you also use the forms engine.
Error UI? Handle in action catch + Toast / Alert.
CE .action? Assign a function property; attributes alone cannot carry the async callback.
Does React forward refs? Yes, to the underlying <button>.
Re-entry? A new press aborts the prior in-flight signal.
Bundle tip? Import @sometic/react/button (or Vue / elements matching subpaths).