useTransition y startTransition: la transición concurrente
Qué es una transición concurrente y qué problema resuelve: cuando un cambio de estado dispara trabajo asíncrono, el Suspense que envolvía el contenido detecta el recurso pendiente, desmonta lo que había y pinta su fallback, con parpadeo, pérdida de scroll y de foco. Una transición marca esa actualización como coordinación asíncrona no urgente, de modo que Solid prepara la vista nueva sin tirar la vieja y solo la compromete cuando todo lo pendiente ha resuelto. Diferencia entre useTransition, que expone un signal pending, y startTransition, que es la forma global sin indicador, y por qué esto es concurrencia cooperativa y no time-slicing.
Cambiar un estado que dispara trabajo asíncrono tiene, por defecto, un coste brutal para la experiencia. El <Suspense> que envolvía el contenido detecta que un recurso volvió a estar pendiente, desmonta lo que había y pinta su fallback: el usuario ve un parpadeo, pierde la posición de scroll, pierde el foco del input que estaba escribiendo. Una transición es la primitiva que rompe ese reflejo. Marca una actualización como coordinación asíncrona no urgente, y con esa etiqueta Solid deja de desmontar: prepara la vista nueva mientras mantiene visible la vieja, y solo hace el cambio —de golpe— cuando todo lo pendiente ha resuelto.
- Definir qué es una transición concurrente y qué problema concreto resuelve.
- Distinguir
useTransitiondestartTransitiony saber cuándo usar cada uno. - Entender que una transición coordina los
<Suspense>que dispara en lugar de mostrar su fallback. - Ver por qué Solid llama a esto concurrencia cooperativa y no time-slicing.
El problema: el fallback que parpadea
Recuerda cómo se comporta <Suspense> sin transiciones. Envuelve una lectura de recurso, y cada vez que ese recurso pasa a estado pendiente —la primera carga o cualquier refetch posterior— el límite desmonta a sus hijos y muestra el fallback. Para el arranque inicial es justo lo que quieres. Para una actualización posterior es un desastre: el contenido que el usuario estaba mirando desaparece y reaparece.
import { createResource, createSignal, Suspense } from "solid-js";
const [id, setId] = createSignal(1);
const [usuario] = createResource(id, obtenerUsuario);
function Perfil() {
return (
<Suspense fallback={<Cargando />}>
<Ficha datos={usuario()} />
</Suspense>
);
}
// En algún manejador de evento:
setId(2); // usuario vuelve a pending -> Suspense DESMONTA Ficha -> parpadeo
Cada setId posterior repite el ciclo: recurso pendiente, fallback, contenido nuevo. El árbol se destruye y se reconstruye, y con él se van tres cosas que el usuario nota aunque no sepa nombrarlas: la posición de scroll vuelve arriba, el foco del teclado salta al body, y cualquier estado de DOM no controlado —una selección de texto, un vídeo en reproducción, una animación a medias— se pierde. El problema no es el <Suspense> —hace exactamente lo que promete—, sino que le estamos pidiendo que trate una actualización con la misma brusquedad que un arranque en frío.
La transición: coordinar en vez de desmontar
Una transición cambia ese contrato. Al envolver la escritura en startTransition, le dices a Solid que la propagación resultante es una transición: si por el camino algún recurso suspende, no muestres el fallback del <Suspense> que ya tenía contenido; retén lo viejo, computa lo nuevo aparte, y compromételo entero cuando todas las promesas hayan resuelto.
import { startTransition } from "solid-js";
// La actualización se marca como transición:
startTransition(() => setId(2));
// La Ficha vieja permanece visible; cuando obtenerUsuario(2) resuelve,
// se cambia de golpe, sin pasar por el fallback
La clave es que la transición abarca todo lo asíncrono que dispara. Si cambiar id provoca el refetch de tres recursos leídos en distintos rincones del árbol, la transición espera a los tres y los presenta a la vez.
// Un solo cambio, tres recursos que dependen de él:
const [perfil] = createResource(id, obtenerPerfil);
const [posts] = createResource(id, obtenerPosts);
const [amigos] = createResource(id, obtenerAmigos);
// startTransition espera a los TRES antes de comprometer nada:
startTransition(() => setId(2)); // perfil, posts y amigos cambian juntos
No hay ventana en la que veas parte de la vista nueva y parte de la vieja: el compromiso es atómico. Esa coordinación de múltiples fuentes asíncronas bajo una sola actualización es, literalmente, lo que la palabra transición nombra. Sin ella tendrías tres cargas independientes parpadeando a destiempo; con ella, una única conmutación limpia.
useTransition cuando necesitas el estado pending
startTransition es la forma global y sin ceremonia: no requiere dueño reactivo y devuelve una Promise que resuelve cuando la transición termina. Su hermano useTransition hace lo mismo pero además te entrega un signal de pendiente, para que la UI pueda anunciar sutilmente que hay una actualización en vuelo sin desmontar nada.
import { createSignal, useTransition } from "solid-js";
function Selector() {
const [pending, start] = useTransition();
const [id, setId] = createSignal(1);
const elegir = (n: number) => start(() => setId(n));
return (
<nav aria-busy={pending()}>
<button onClick={() => elegir(1)}>Uno</button>
<button onClick={() => elegir(2)}>Dos</button>
</nav>
);
}
pending() es true desde que llamas a start hasta que resuelve la última promesa coordinada. Varias llamadas a start antes de que la primera termine se funden en una única transición viva, así que el indicador refleja el estado real del conjunto, no de la última pulsación. La regla práctica: usa startTransition cuando solo quieres la coordinación, y useTransition cuando además vas a pintar un indicador de que algo se está preparando.
useTransition crea estado reactivo —el signal pending— así que debe llamarse dentro de un dueño: el cuerpo de un componente o un createRoot. startTransition es una función libre que puedes invocar desde cualquier sitio, incluido un manejador definido fuera de todo componente o una utilidad compartida. Esta diferencia decide por ti en los bordes: si necesitas disparar una transición desde código sin dueño reactivo, startTransition es tu única opción, y el pending tendrás que obtenerlo por otra vía —como useIsRouting cuando se trata de una navegación.
flowchart LR U[cambio de estado] -->|start| T[transicion] T --> F[grafo clonado computa lo nuevo] F -->|resources fetching| P[pending true y UI vieja visible] P -->|async resuelto| C[commit atomico] C --> V[UI nueva y pending false] style P fill:#f9e2af,color:#11111b style C fill:#a6e3a1,color:#11111b
Coordinación, no render
Una transición no re-renderiza: coordina las fuentes async que dispara y compromete el resultado de una vez.
La vieja se queda
El contenido ya comprometido permanece visible; el fallback del Suspense no vuelve a aparecer durante la transición.
pending opcional
startTransition solo coordina; useTransition añade un signal de pendiente para anunciar la espera.
Tanto startTransition como el start de useTransition devuelven una Promise<void> que resuelve cuando la transición se ha comprometido. Eso te permite programar el después con precisión: hacer scroll al nuevo contenido, enfocar el primer elemento, o disparar una segunda navegación solo cuando la primera ya está en pantalla. await start(() => setId(2)) no continúa hasta que la Ficha nueva es real, no cuando la pediste.
Un error frecuente es meter todo dentro de startTransition, incluida la escritura que debe responder al instante. Si el usuario teclea en un input, el valor del input pertenece al carril urgente y debe actualizarse de inmediato; solo la búsqueda cara que ese valor dispara pertenece a la transición. Envolver la escritura urgente la hace sentir lenta sin ganar nada, porque no había nada asíncrono que coordinar en ella. Separa siempre la escritura que da feedback inmediato de la que lanza el trabajo diferido.
El error de intuición más común es pensar que una transición hace que los datos lleguen antes. No toca la latencia en absoluto: el fetch tarda lo que tarda. Lo que la transición cambia es qué se muestra durante esa espera. Sin ella, el estado pendiente es visible —el fallback ocupa el sitio del contenido— y el usuario paga la latencia con un parpadeo. Con ella, el estado pendiente se vuelve invisible: la vista anterior sigue en pantalla, completamente utilizable, mientras la nueva se cocina en una rama clonada del grafo reactivo que nadie ve todavía. Cuando esa rama termina, Solid la compromete de golpe. Aquí está la palabra concurrencia, y conviene desactivar el reflejo que traes de React: Solid no hace time-slicing, no trocea el trabajo síncrono en fragmentos interrumpibles ni tiene un planificador que reparte prioridades sobre la CPU. Su reactividad de grano fino ya es tan barata que no necesita trocearse. La concurrencia de Solid es cooperativa y vive en otro plano: mantiene dos versiones de una porción del grafo —la comprometida y la que se está preparando— y decide cuándo canjear una por otra. Por eso una transición es, en el fondo, una política sobre el tiempo asíncrono: declara que cierta actualización puede tardar en materializarse y que, mientras tanto, la verdad que el usuario ve es la de antes. Esa inversión —la espera existe pero deja de ser visible— es todo el valor de la primitiva, y es la base de las cuatro lecciones que siguen: mantener lo viejo sin parpadeo, anunciar la espera con pending, aplicarla a la navegación, y entender dónde encaja con Suspense y hacia dónde la lleva Solid 2.0.
- Monta un
<Suspense>sobre un recurso cuya fuente sea un signalidy confirma, cambiandoida mano, que el fallback reaparece en cada cambio. - Envuelve la escritura en
startTransition(() => setId(n))y verifica que ahora el contenido viejo permanece hasta que llega el nuevo. - Haz que
idalimente tres recursos y comprueba que la transición los conmuta a la vez, sin cargas independientes a destiempo. - Cambia a
useTransition, pintaaria-busy={pending()}y usaawait start(...)para hacer scroll al contenido nuevo solo cuando ya está en pantalla. - Dispara dos
startseguidos muy rápido y explica por quépending()no baja hasta que la segunda transición resuelve.