isPending: el indicador sutil que no desmonta
El signal pending de useTransition como tercer estado de la interfaz: ni vacío ni resuelto, sino refrescándose sobre contenido que sigue presente. Patrones de indicador sutil que anotan en lugar de reemplazar, atenuar con opacidad, marcar aria-busy, una barra de progreso fina, deshabilitar el envío, sin desmontar nunca el contenido actual. Accesibilidad de la espera, comunicar que algo llega sin robar el foco ni romper la navegación por teclado, y cómo fundir varias transiciones en un único indicador coherente cuando conviven varias fuentes de pendiente.
Suspense te daba dos estados: cargando —el fallback— y resuelto —el contenido—. Las transiciones introducen un tercero que antes no tenías nombre para expresar: refrescándose. El contenido sigue ahí, completo y utilizable, pero por debajo se está preparando una versión nueva. El signal pending de useTransition es la señal de ese tercer estado, y su regla de oro es que anota, no reemplaza: en lugar de tapar la vista con un spinner, la deja intacta y le añade una pista discreta de que algo viene en camino.
- Reconocer
pendingcomo el tercer estado de la UI: ni vacío ni resuelto, sino refrescándose. - Aplicar indicadores sutiles —opacidad,
aria-busy, barra de progreso— sin desmontar nada. - Comunicar la espera de forma accesible sin robar el foco ni romper la navegación por teclado.
- Fundir varias transiciones en un único indicador coherente.
pending: el estado que Suspense no te daba
useTransition devuelve el par [pending, start]. pending() es un signal booleano que vale true mientras la transición está en vuelo y vuelve a false cuando se compromete. Su valor no lo lees para decidir si pintar contenido —el contenido no se fue a ninguna parte—, sino para decorar la vista existente con una marca de que se está actualizando.
import { createSignal, Suspense, useTransition } from "solid-js";
function Panel() {
const [pending, start] = useTransition();
const [pestana, setPestana] = createSignal("resumen");
return (
<section aria-busy={pending()} classList={{ atenuado: pending() }}>
<Tabs onChange={(t) => start(() => setPestana(t))} />
<Suspense fallback={<Skeleton />}>
<Contenido tab={pestana()} />
</Suspense>
</section>
);
}
El <Suspense> interno solo actuará en frío, la primera vez. A partir de ahí, cada cambio de pestaña dentro de start mantiene el contenido de la pestaña anterior y enciende pending(). El CSS hace el resto de forma no intrusiva:
.atenuado {
opacity: 0.6;
transition: opacity 0.2s ease;
pointer-events: none; /* opcional: evita clics sobre datos que van a cambiar */
}
Conviene fijarse en un detalle de timing: pending() sube en cuanto llamas a start, sin esperar un solo milisegundo, así que el feedback es inmediato aunque los datos tarden. Esa inmediatez es la mitad de la sensación de rapidez; la otra mitad es que el contenido nunca se va. El usuario ve, al instante, una vista válida marcada como en curso —jamás un hueco.
flowchart LR A[estado vacio y muestra fallback] --> B[estado refrescandose contenido visible con anotacion] B --> C[estado resuelto y contenido definitivo] style A fill:#f38ba8,color:#11111b style B fill:#f9e2af,color:#11111b style C fill:#a6e3a1,color:#11111b
Indicadores que anotan, no que reemplazan
La familia de indicadores sutiles comparte un principio: ninguno quita el contenido de en medio. Atenuar con opacidad comunica esto que ves es de hace un instante. Una barra de progreso fina en el borde superior comunica hay actividad sin ocupar espacio de layout. Deshabilitar el botón que disparó la transición evita dobles envíos sin bloquear el resto de la página. Cambiar el cursor a progress da un microfeedback inmediato. Todos son reversibles con un solo signal.
function BarraSuperior(props: { activa: boolean }) {
return (
<div
class="barra-progreso"
classList={{ corriendo: props.activa }}
role="presentation"
/>
);
}
// <BarraSuperior activa={pending()} /> anclada arriba del todo
La diferencia con el fallback de Suspense es cualitativa, no de grado. El fallback dice aquí no hay nada todavía, espera; el indicador de pending dice aquí hay algo válido, y viene algo mejor. Elegir el segundo cuando de verdad tienes contenido previo es lo que separa una interfaz que respeta la atención del usuario de una que la interrumpe a cada clic.
Accesibilidad: comunicar la espera sin romper el foco
El indicador visual no basta: la espera también debe existir para quien no la ve. El atributo aria-busy="true" sobre la región que se actualiza informa a los lectores de pantalla de que su contenido está en transición. Como el contenido no se desmonta, el foco del teclado permanece donde estaba —un mérito silencioso de las transiciones frente al fallback, que al desmontar tira el foco al body y descoloca la navegación.
<section aria-busy={pending()}>
{/* el foco del usuario sobrevive porque nada se desmonta */}
<Resultados datos={datos()} />
<div aria-live="polite" class="sr-only">
{pending() ? "Actualizando resultados" : `${datos()?.length ?? 0} resultados`}
</div>
</section>
Una región aria-live="polite" que cambie al terminar da la noticia sin interrumpir: anuncia cuántos elementos trajo el filtro nuevo justo cuando la transición resuelve. La combinación es potente: aria-busy durante la espera, aria-live al resolver, y el foco intacto todo el tiempo. La accesibilidad deja de ser un parche y se vuelve una consecuencia natural de no destruir la vista.
Fundir varias transiciones en un indicador
En una pantalla real conviven varias fuentes de pendiente: un filtro, una ordenación, quizá una navegación de fondo. Puedes darle a cada una su useTransition y luego combinar sus signals en un único indicador global con una simple disyunción, para que la barra superior refleje cualquier actividad sin duplicar UI.
const [pFiltro, startFiltro] = useTransition();
const [pOrden, startOrden] = useTransition();
const ocupado = () => pFiltro() || pOrden();
// <BarraSuperior activa={ocupado()} /> anuncia cualquiera de las dos
La decisión de si conviene un indicador único o varios locales depende del significado: si dos transiciones afectan a zonas distintas de la pantalla, dos atenuaciones locales comunican mejor qué está cambiando; si solo quieres una señal global de hay algo en curso, la disyunción basta. Pero como pending es un signal ordinario, componer varios es tan trivial como componer cualquier derivación, y esa es una ventaja que las APIs de carga menos reactivas no te dan.
Es fácil confundirlos porque suelen coincidir, pero miden cosas distintas. resource.loading es true cuando ese recurso concreto está buscando datos, exista o no una transición. pending() es true cuando la transición está en vuelo, y una sola transición puede abarcar varios recursos a la vez. Para atenuar un bloque local por su propia carga, loading es lo natural; para anunciar que toda una actualización coordinada está en curso, pending es la señal correcta. Elige según el alcance de lo que quieres comunicar.
Opacidad
Atenuar el bloque que va a cambiar comunica staleness sin quitarlo. Reversible con un classList.
Barra fina
Una barra de progreso en el borde anuncia actividad sin robar espacio de layout ni mover contenido.
Deshabilitar origen
Bloquear solo el control que disparó la transición evita dobles envíos sin congelar la página entera.
Poner pointer-events: none en el bloque atenuado es tentador para evitar clics sobre datos que van a cambiar, pero piénsalo por caso. En una lista que se refina con un filtro, quizá quieras que el usuario pueda seguir haciendo scroll y leyendo mientras llega el resultado nuevo. En un formulario a medio rellenar, desde luego no quieres congelar los campos. La atenuación comunica staleness; bloquear la interacción es una decisión aparte que solo procede cuando actuar sobre el dato viejo sería un error. Separa las dos cosas y decídelas por separado.
Antes de las transiciones, tu interfaz vivía en un binario empobrecido: o mostrabas datos o mostrabas un spinner, y cualquier actualización te obligaba a elegir entre dos malas opciones —parpadear al spinner o mentir mostrando datos viejos sin avisar de que lo eran—. pending disuelve ese falso dilema porque nombra el estado intermedio que la realidad siempre contuvo: el momento en que lo que ves es cierto pero provisional, válido ahora y a punto de ser reemplazado. Y al nombrarlo te da algo que hacer con él que no es ni ocultar ni engañar, sino anotar. Esta es la razón profunda de que la regla sea anotar y no reemplazar. Reemplazar —tapar con un fallback— es tratar la actualización como si fuera un arranque, tirar una verdad utilizable para poner una ausencia. Anotar es respetar que el usuario tiene delante algo con lo que puede seguir trabajando y limitarse a susurrarle que llega una versión mejor. Todo el catálogo de indicadores sutiles —la opacidad, la barra fina, el botón deshabilitado, el aria-busy— son variaciones de ese susurro, y todos son posibles por la misma razón técnica: como la transición no desmonta, el contenido, el scroll y el foco siguen ahí para ser anotados. Que pending sea además un signal ordinario, componible con cualquier otro, es lo que te deja escalar ese susurro desde un bloque local hasta un indicador global sin cambiar de herramienta. Cuando interiorizas que refrescándose es un estado de primera clase y no un hueco entre otros dos, dejas de diseñar cargas como interrupciones y empiezas a diseñarlas como continuidades, que es exactamente la sensación que distingue una aplicación que se siente rápida de una que solo lo es.
- Envuelve un cambio de pestaña en
useTransitiony ataaria-busy={pending()}y una clase de atenuación al contenedor. - Añade una barra de progreso fina en el borde superior gobernada por el mismo
pending()y comprueba que no desplaza el layout. - Verifica con el teclado que el foco sobrevive a la transición, y contrástalo con la versión sin transición donde el fallback lo pierde.
- Añade una región
aria-live="polite"que anuncie el número de resultados al resolver y compruébalo con un lector de pantalla. - Convive dos
useTransitiondistintos, funde sus pendientes enp1() || p2()para un indicador global, y razona cuándo conviene ese indicador único frente a dos atenuaciones locales.