Mantener la UI vieja visible mientras la nueva se prepara
El mecanismo fino de por qué no hay parpadeo: un Suspense que ya resolvió al menos una vez no vuelve a su fallback dentro de una transición, sino que retiene su último contenido comprometido mientras la rama nueva se prepara. Diferencia entre arranque en frío, sin contenido previo donde el fallback sí es correcto, y arranque en caliente, donde la transición conserva lo viejo. El commit atómico que elimina el estado intermedio y evita el tearing, y el uso de resource.latest para leer el valor anterior mientras el nuevo se busca, incluso sin envolver en transición.
La promesa de una transición es concreta: cero parpadeo. Pero conviene entender el mecanismo exacto que lo consigue, porque tiene una condición previa que a veces sorprende. Un <Suspense> solo puede retener contenido viejo si alguna vez tuvo contenido que retener. La primera carga de la vida sigue mostrando el fallback —no hay nada anterior que conservar—; es a partir de la segunda actualización cuando la transición despliega su magia, sosteniendo la vista comprometida mientras la nueva se prepara en silencio y comprometiéndolas de una sola vez, nunca a medias.
- Explicar por qué un
<Suspense>ya resuelto no vuelve a su fallback dentro de una transición. - Distinguir el arranque en frío, sin contenido previo, del arranque en caliente.
- Entender el commit atómico que elimina el estado intermedio y evita el tearing.
- Usar
resource.latestpara leer el valor anterior mientras el nuevo se busca.
Suspense frío y Suspense caliente
Un <Suspense> tiene, para lo que nos ocupa, dos regímenes. En frío nunca ha comprometido contenido: su rama interna está pendiente por primera vez y no existe ninguna vista anterior. Ahí el fallback es la respuesta correcta, y una transición no lo cambia —no hay nada viejo que sostener—. En caliente el límite ya resolvió al menos una vez y tiene hijos comprometidos en el DOM. Si en ese estado una transición dispara un nuevo pendiente, Solid decide retener lo comprometido en lugar de volver al fallback.
// Primera carga (frío): id nunca resolvió -> Suspense muestra fallback
const [id, setId] = createSignal(1);
const [usuario] = createResource(id, obtenerUsuario);
// Más tarde (caliente): Ficha ya está en pantalla
startTransition(() => setId(2));
// Suspense NO vuelve al fallback: retiene la Ficha de id=1
// hasta que obtenerUsuario(2) resuelve
La consecuencia de diseño es importante: las transiciones no mejoran el arranque inicial. Para eso están el fallback bien diseñado, el streaming del servidor o los datos precargados. Las transiciones gobiernan las actualizaciones de una vista que ya existe. Confundir ambos momentos lleva a esperar que una transición borre el spinner de la primera pantalla, cosa que jamás hará.
Merece la pena nombrar por qué el régimen caliente es seguro. Retener contenido viejo solo es honesto si ese contenido sigue siendo una respuesta válida —anticuada, pero válida— a lo que el usuario ve. Y lo es: la Ficha de id=1 describía correctamente al usuario 1, y mientras la del 2 no esté lista, seguir mostrando la del 1 con una marca de que algo cambia no engaña a nadie. El fallback, en cambio, destruye una verdad usable para poner una ausencia. La transición prefiere la verdad anticuada anotada sobre la ausencia limpia, y en la enorme mayoría de las actualizaciones esa preferencia es la correcta.
El commit atómico: sin estados a medias
El segundo pilar del cero parpadeo es que la transición se comprometa de forma atómica. Mientras dura, Solid trabaja sobre una rama clonada del grafo: aplica ahí la escritura, deja que los recursos afectados refetcheen y observa los límites <Suspense> internos. Nada de eso toca el DOM que el usuario ve. Solo cuando todos los pendientes de la rama han resuelto, se canjea la vista vieja por la nueva en un único paso.
flowchart TD A[Suspense se monta la primera vez] -->|sin contenido previo| C[muestra fallback en frio] B[Suspense ya resolvio antes] -->|dentro de transicion| D[retiene lo viejo visible] D -->|todos los async resueltos| E[commit atomico de lo nuevo] style C fill:#f38ba8,color:#11111b style D fill:#f9e2af,color:#11111b style E fill:#a6e3a1,color:#11111b
Esa atomicidad es lo que elimina el tearing: el estado inconsistente en el que una parte de la interfaz refleja los datos nuevos y otra los viejos. Sin transición, si dos recursos que dependen del mismo id resuelven en instantes distintos, el usuario ve durante unos milisegundos una cabecera del usuario nuevo junto a una lista del viejo. La transición prohíbe ese fotograma: retiene todo lo viejo, junto, hasta que todo lo nuevo está listo, junto. La coherencia visual deja de ser un accidente afortunado del timing y pasa a ser una garantía.
Esta garantía escala con la complejidad de la vista. Cuantas más fuentes asíncronas dependan de un mismo cambio, más valiosa es la atomicidad, porque más maneras hay de que el timing produzca fotogramas inconsistentes. Un panel de detalle que carga a la vez el perfil, las estadísticas y el historial de un usuario tiene tres relojes distintos corriendo; sin transición verías el orden aleatorio en que llegan, con transición ves una sola conmutación limpia. La transición convierte tres condiciones de carrera visuales en cero.
resource.latest: el valor de ayer mientras llega el de hoy
Hay un complemento de grano más fino para cuando no quieres retener toda la vista sino solo mostrar el dato anterior de un recurso concreto mientras el nuevo se busca: resource.latest. Leer el recurso como función —datos()— suspende si está pendiente y lo captura el <Suspense>. Leer datos.latest devuelve el último valor resuelto sin suspender, así que puedes pintar los resultados viejos, atenuados, mientras el refetch corre por debajo.
const [datos] = createResource(consulta, buscar);
// datos() -> suspende si está pendiente (lo atrapa Suspense)
// datos.latest -> el último valor resuelto, sin suspender
<ul classList={{ obsoleto: datos.loading }}>
<For each={datos.latest ?? []}>
{(x) => <li>{x.titulo}</li>}
</For>
</ul>
resource.latest y las transiciones no compiten: se combinan. La transición gobierna el compromiso atómico de la vista completa; latest te da control local para decidir qué recurso concreto muestra su valor previo durante la espera. En una lista con paginación es el patrón exacto: quieres los resultados de la página anterior visibles y ligeramente apagados mientras la siguiente carga.
function Paginada() {
const [pagina, setPagina] = createSignal(1);
const [datos] = createResource(pagina, buscarPagina);
const siguiente = () => startTransition(() => setPagina((p) => p + 1));
return (
<>
<ul classList={{ obsoleto: datos.loading }}>
<For each={datos.latest ?? []}>{(x) => <li>{x.titulo}</li>}</For>
</ul>
<button onClick={siguiente} disabled={datos.loading}>Siguiente</button>
</>
);
}
El criterio para elegir entre retención total —la transición— y retención parcial —latest— es qué coherencia necesitas. Si varias piezas de la vista deben cambiar juntas o no cambiar, usa la transición y su commit atómico; si solo te importa que una lista no parpadee y el resto puede ir a su ritmo, latest es más barato y local. En la práctica se mezclan: una transición envuelve el cambio grande y latest afina el detalle de un recurso concreto dentro de ella.
La retención tiene límites: errores y esperas muy largas
Retener lo viejo asume que lo nuevo va a llegar. ¿Y si la petición falla? La transición no oculta el error: si el recurso rechaza, la rama en preparación lanza, y el <ErrorBoundary> más cercano lo captura. El contenido viejo se mantuvo hasta ese instante, así que el usuario pasa de una vista válida a un estado de error explícito, sin un fallback intermedio que prometiera datos que nunca vinieron.
<ErrorBoundary fallback={(e) => <Aviso error={e} />}>
<Suspense fallback={<Skeleton />}>
<Ficha datos={usuario()} />
</Suspense>
</ErrorBoundary>
La otra frontera es la espera muy larga. Como la vista vieja no parpadea, una transición que tarda diez segundos no da, por sí sola, ninguna señal de que algo va mal; por eso el indicador de pendiente de la lección siguiente no es un lujo estético, sino el complemento necesario para que la retención no se confunda con un cuelgue.
Mientras la transición retiene lo viejo, el recurso subyacente está de verdad pendiente, así que resource.loading es true aunque no veas ningún fallback. Esa es justamente la señal que aprovechas para atenuar con classList: el contenido es válido y visible, pero loading te dice que es la versión anterior. No confundas ausencia de fallback con ausencia de carga; la carga ocurre, solo que en una rama que no has desmontado.
Frío: fallback correcto
Sin contenido previo, el fallback es la respuesta legítima. Las transiciones no tocan la primera carga.
Caliente: retención
Con contenido ya comprometido, la transición sostiene lo viejo y suprime el fallback.
Commit atómico
Todo lo nuevo entra a la vez. Nunca ves una vista mitad vieja, mitad nueva: sin tearing.
Si tu spinner inicial te molesta, no lo arregles con una transición: no puede, porque no hay contenido anterior que retener. Ese problema se resuelve aguas arriba —SSR con streaming, un esqueleto bien diseñado, o precargar el recurso antes de montar el árbol para que llegue ya resuelto—. Reserva las transiciones para lo que sí saben hacer: que la vista número dos, tres y siguientes no parpadeen. Pedirle a la herramienta el trabajo de otra fase es la fuente más común de frustración con esta API.
Lo que hace elegante a este mecanismo es que no hay ningún truco de animación ni de ocultar el spinner con CSS: el parpadeo no se disimula, se elimina en su raíz, porque durante la transición existen literalmente dos versiones del contenido y el usuario siempre ve una completa. La versión comprometida —la de antes— permanece intacta en el DOM, viva y utilizable, porque Solid nunca la desmontó. La versión en preparación —la de después— crece en una rama clonada del grafo reactivo, invisible, donde los recursos refetchean y los <Suspense> internos evalúan si están listos. El sistema sostiene ambas verdades simultáneamente y solo cuando la segunda está entera la canjea por la primera. De ahí salen las dos condiciones que definen la primitiva. La primera, que hace falta una verdad anterior que sostener: por eso el arranque en frío muestra fallback, no por una excepción caprichosa, sino porque no existe nada que retener y la transición no tiene sobre qué operar. La segunda, que el canje es atómico: por eso desaparece el tearing, porque la vista vieja no se va desmontando pieza a pieza a medida que llegan datos sueltos, sino que se mantiene íntegra hasta el instante exacto en que la nueva puede reemplazarla completa. Entender esto reordena tu manera de diseñar cargas: dejas de preguntarte cómo escondo el estado de carga y empiezas a preguntarte qué verdad quiero que el usuario vea mientras la siguiente se prepara. Casi siempre la respuesta es la verdad de hace un momento, íntegra y usable, y esa es precisamente la que la transición conserva por ti.
- Monta un recurso paginado y confirma que la primera página muestra fallback pero las siguientes, dentro de
startTransition, retienen la página anterior. - Provoca un caso de tearing sin transición leyendo dos recursos con latencias distintas del mismo
id; observa el fotograma inconsistente. - Envuelve el cambio de
iden una transición y verifica que el fotograma inconsistente desaparece: ambos recursos cambian juntos. - Sustituye la retención total por
resource.latesty pinta los resultados viejos atenuados conclassListmientrasresource.loadingestrue. - Explica en una frase por qué una transición no puede eliminar el spinner de la primera carga y qué técnica sí lo haría.