Botón de acción asíncrona
Ejecuta una promesa con estados de carga, éxito y error; permite reintentar y evita acciones duplicadas.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useState, useEffect, useRef, type CSSProperties } from "react";
/** Botón de acción asíncrona. React + TypeScript, sin dependencias. Personaliza con variables --gcxa-*. */
export type BotonAsincronoProps = {
label?: string;
action?: () => Promise<void>;
disabled?: boolean;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcxa-root { box-sizing: border-box; min-width: 0; max-width: 100%; color: var(--gcxa-fg, #202421); font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.gcxa-root *, .gcxa-root *::before, .gcxa-root *::after { box-sizing: border-box; }
.gcxa-root button, .gcxa-root input, .gcxa-root textarea, .gcxa-root select { font: inherit; }
.gcxa-root button, .gcxa-root a { -webkit-tap-highlight-color: transparent; }
.gcxa-root :focus-visible { outline: 2px solid var(--gcxa-accent, #25724b); outline-offset: 4px; }
.gcxa-root ::selection { background: var(--gcxa-accent, #25724b); color: #fff; }
.gcxa-root button:disabled { opacity: .5; cursor: not-allowed; }
.gcxa-root svg { flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) { .gcxa-root *, .gcxa-root *::before, .gcxa-root *::after { animation: none !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
.gcxa-root { text-align: center; }
.gcxa-root button { display: inline-flex; gap: 12px; align-items: center; border: 0; border-radius: 8px; min-height: 48px; padding: 0 24px; color: #fff; background: var(--gcxa-accent, #202421); cursor: pointer; transition: background .2s, transform .15s; }
.gcxa-root button:hover { background: #3b493f; }
.gcxa-root button:active { transform: translateY(1px); }
.gcxa-root button[data-state="success"] { background: #236340; }
.gcxa-root p { min-height: 18px; margin: 12px 0 0; font-size: 12px; }
.gcxa-icon { width: 18px; display: grid; place-items: center; }
.gcxa-spinner { width: 16px; height: 16px; border: 2px solid #ffffff65; border-top-color: #fff; border-radius: 50%; animation: gcxa-spin .7s linear infinite; }
@keyframes gcxa-spin { to { transform: rotate(360deg); } }`;
export function BotonAsincrono({
label = "Guardar cambios",
action,
disabled,
className = "",
style,
}: BotonAsincronoProps) {
const [state, setState] = useState<"idle" | "loading" | "success" | "error">(
"idle",
);
const mounted = useRef(false);
const busy = useRef(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
if (timer.current) clearTimeout(timer.current);
};
}, []);
async function run() {
if (busy.current) return;
busy.current = true;
setState("loading");
try {
if (action) await action();
else
await new Promise<void>((resolve) => {
timer.current = setTimeout(resolve, 900);
});
if (mounted.current) setState("success");
} catch {
if (mounted.current) setState("error");
} finally {
busy.current = false;
}
}
return (
<div className={`gcxa-root ${className}`} style={style}>
<style>{CSS}</style>
<button
type="button"
onClick={run}
disabled={disabled || state === "loading"}
aria-busy={state === "loading"}
data-state={state}
>
<span className="gcxa-icon" aria-hidden="true">
{state === "loading" ? (
<span className="gcxa-spinner" />
) : (
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
>
<path
d={state === "success" ? "m5 12 4 4 10-10" : "M12 5v14m-7-7h14"}
/>
</svg>
)}
</span>
{state === "loading"
? "Guardando…"
: state === "success"
? "Guardado · repetir"
: state === "error"
? "Reintentar"
: label}
</button>
<p role="status">
{state === "error"
? "No se pudo guardar. Inténtalo de nuevo."
: state === "success"
? "Cambios guardados."
: ""}
</p>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/boton-asincronoEjemplo de uso de Botón de acción asíncrona
Después de copiar boton-asincrono.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { BotonAsincrono } from "./boton-asincrono";
export default function Ejemplo() {
return <BotonAsincrono label="Ejemplo" />;
}Propiedades de BotonAsincrono
BotonAsincrono acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
action? | () => Promise<void> | Opcional. |
disabled? | boolean | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Botón de acción asíncrona con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gcxa-fg | #202421 |
--gcxa-accent | #25724b |
Accesibilidad y rendimiento
El archivo tiene 117 líneas (4 KB) y define 1 animación con @keyframes. Esto es lo que hace Botón de acción asíncrona por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: status.
- Es un componente de cliente de Next.js: incluye la directiva "use client".