Transiciones concurrentes: mantener lo viejo mientras carga lo nuevo
Durante dos décadas la interfaz web solo supo hacer dos cosas cuando el usuario pedía algo que tardaba: bloquear o parpadear. Bloquear significa congelar la interacción hasta que llega el dato; parpadear significa destruir la pantalla actual, mostrar un esqueleto y reconstruirla después. Las transiciones concurrentes proponen una tercera salida que consiste en renderizar el árbol nuevo en segundo plano mientras el árbol viejo sigue vivo, visible y respondiendo a las entradas, y presentarlo solo cuando está listo. Esta lección explica el modelo de dos urgencias que hace posible esa idea, muestra por qué exige que el render sea interrumpible y por tanto libre de efectos secundarios, convierte el estado pendiente en un dato de primera clase con su propia semántica visual, y delimita con precisión los casos en los que la técnica no aplica o produce una interfaz peor que el parpadeo honesto que pretendía evitar.
Cuando alguien pulsa una pestaña cuyo contenido tarda seiscientos milisegundos en llegar, la interfaz tradicional solo dispone de dos gestos y los dos son malos. Puede desmontar lo que había y poner un esqueleto, con lo que destruye información todavía útil, provoca un salto de maquetación y castiga con una pantalla vacía a quien tenía buena conexión y habría esperado sin enterarse. O puede quedarse bloqueada esperando el dato, con lo que la aplicación deja de responder y el usuario duda de si su pulsación se registró. Las transiciones concurrentes rompen esa disyuntiva con un cambio de modelo, no con un truco de presentación: separan las actualizaciones en dos clases de urgencia y permiten que el motor trabaje en la pantalla futura sin desmontar la presente, manteniendo la vieja completamente viva mientras la nueva se construye en segundo plano. El resultado no es solo estético. Es una forma distinta de repartir el tiempo de la máquina entre lo que el usuario acaba de hacer y lo que ese acto implica.
- Situar el falso dilema entre bloquear y parpadear y por qué ninguna de las dos opciones es aceptable.
- Explicar el modelo de dos urgencias y qué exige del render para que sea interrumpible.
- Tratar el estado pendiente como un dato con semántica visual propia y no como un indicador genérico.
- Reconocer los casos en los que una transición perjudica la percepción en lugar de mejorarla.
Dos urgencias, dos árboles
La observación de partida es que no todas las actualizaciones de estado tienen el mismo contrato con la persona. Cuando alguien escribe una letra, el cursor y el carácter deben aparecer en el siguiente fotograma sin excepción, porque una latencia de cien milisegundos en la respuesta del teclado se percibe como una avería. Cuando esa misma letra provoca la carga de una lista filtrada, nadie espera que la lista aparezca instantáneamente. Son dos actualizaciones disparadas por el mismo evento con dos exigencias temporales que difieren en un orden de magnitud, y un sistema que las trate igual tendrá que elegir entre dos malas opciones: bloquear el teclado esperando la lista, o vaciar la lista para no bloquear el teclado.
// Marcar la parte no urgente permite que el teclado no espere a la lista.
const [texto, setTexto] = useState("")
const [consulta, setConsulta] = useState("")
const [pendiente, empezarTransicion] = useTransition()
function alEscribir(v: string) {
setTexto(v) // urgente: se ve ya
empezarTransicion(() => setConsulta(v)) // no urgente: puede esperar
}
// Mientras la transicion trabaja, la lista antigua sigue en pantalla,
// visible y utilizable, y pendiente vale true para poder atenuarla.
La clave del mecanismo, y lo que lo distingue de cualquier retardo o antirrebote, es que existen dos árboles a la vez. El motor conserva el árbol actual montado y pintado mientras construye una versión nueva en memoria a partir del estado nuevo; si durante esa construcción llega otra entrada urgente, el trabajo en curso se descarta o se reanuda más tarde y la entrada urgente se atiende de inmediato. El árbol nuevo solo sustituye al viejo cuando está completo. Por eso la técnica no es una animación ni un aplazamiento: es una reorganización del reparto del tiempo de cómputo entre dos versiones simultáneas del mismo estado.
flowchart TD A[pulsacion del usuario] --> B[actualizacion urgente] A --> C[actualizacion marcada como transicion] B --> D[se pinta en el fotograma siguiente] C --> E[render del arbol nuevo en segundo plano] E --> F[el arbol viejo sigue montado y visible] E -->|llega otra entrada urgente| G[descartar trabajo y reempezar] E -->|termina| H[intercambiar arbol viejo por nuevo] style D fill:#a6e3a1,color:#11111b style H fill:#a6e3a1,color:#11111b
Esta arquitectura impone una condición que conviene entender porque explica muchas reglas que de otro modo parecen arbitrarias. Si el motor puede empezar un render, abandonarlo a medias y volver a empezarlo, entonces la fase de render no puede tener efectos observables fuera de sí misma. Nada de escribir en una variable de módulo, nada de disparar una petición, nada de mutar un objeto compartido, nada de leer un valor que pueda haber cambiado entre dos intentos. La pureza del render deja de ser una recomendación de estilo y pasa a ser el requisito que hace posible la interrupción. Un componente impuro no produce un aviso: produce un fallo intermitente que aparece solo cuando el render que dejó a medias la basura fue precisamente el descartado.
De esa misma exigencia se deriva la regla que gobierna la lectura de estado externo, y que es la fuente de los defectos más difíciles de diagnosticar de todo el modelo. Si un componente lee directamente de una fuente mutable ajena al motor, una caja de estado global, un almacén de un tercero, el objeto de sesión del navegador, nada garantiza que los dos intentos de render lean el mismo valor, y el resultado puede ser un árbol construido con la mitad de los datos viejos y la mitad de los nuevos. Es el fenómeno que se conoce como desgarro, y su nombre está tomado directamente de la literatura de memoria compartida, donde describe la lectura de una palabra mientras otro hilo la escribe. Que reaparezca aquí, en un entorno de un solo hilo, es una confirmación elegante de la tesis de este nivel: el problema nunca fue el paralelismo, fue tener dos versiones del mundo vivas a la vez y no decidir cuál manda.
Un componente que lee de una fuente externa mutable funcionaba perfectamente mientras el render era una operación atómica de principio a fin, porque nadie podía modificar la fuente en mitad del recorrido del árbol. Al volverse interrumpible el render, ese mismo código pasa a poder observar dos valores distintos de la misma fuente dentro del mismo árbol. Por eso existen suscripciones a fuentes externas con una interfaz específica, y por eso una integración casera con un almacén global que se limite a leer y forzar un repintado es correcta en el modo antiguo e incorrecta en cuanto se activa una transición. El síntoma será una incoherencia rara y no reproducible, y la causa estará a tres capas de distancia del sitio donde se manifiesta.
El estado pendiente como dato de primera clase
Mantener la pantalla vieja resuelve la pérdida de información y crea un problema nuevo, porque durante la transición la interfaz está mintiendo: muestra unos datos y la persona ya pidió otros. El puente entre ambas cosas es el estado pendiente, y tratarlo como un simple indicador de carga desperdicia casi todo su valor. Lo que expresa no es que algo se esté cargando, sino que lo visible corresponde a una intención ya superada. Esa afirmación tiene consecuencias visuales concretas y bastante distintas de las de un esqueleto.
Atenuar, no vaciar
Bajar la opacidad del bloque afectado y desactivar sus interacciones comunica obsolescencia sin destruir contenido ni provocar salto de maquetación. Es la traducción visual exacta de lo que ocurre por dentro.
Retrasar la señal
Si la transición termina en menos de doscientos milisegundos, mostrar cualquier indicador empeora la percepción. Un umbral antes de atenuar evita el parpadeo en las conexiones buenas.
Confirmar la acción, no el resultado
El control que originó la transición debe reaccionar de inmediato, marcándose como activo aunque su contenido tarde. Separa el acuse de recibo de la entrega del dato.
Evitar el doble disparo
Con la interfaz vieja aún viva, nada impide pulsar otra vez. El estado pendiente debe usarse también para desactivar acciones que no deben repetirse mientras la anterior no cierra.
Conviene además distinguir el estado pendiente de la carga inicial, porque comparten indicador en muchas implementaciones y no comparten significado. La carga inicial ocurre cuando no hay nada que mostrar y el esqueleto es la respuesta correcta, porque comunica la forma de lo que vendrá y reserva el espacio que ocupará. El estado pendiente ocurre cuando sí hay algo que mostrar y la respuesta correcta es conservarlo. Usar el mismo componente para ambos casos produce la peor combinación posible, que es destruir contenido útil para pintar un armazón que la persona ya ha visto. La distinción es fácil de codificar, basta con preguntar si existen datos previos para la unidad de coherencia actual, y su efecto sobre la percepción es desproporcionado respecto a lo que cuesta.
Hay un detalle de percepción que casi nadie mide y que decide si toda esta maquinaria mejora algo. La ventaja de mantener la pantalla vieja no consiste en que el usuario siga leyendo el contenido antiguo, que rara vez le interesa; consiste en que la pantalla no salta, en que el foco no se pierde, en que la posición del desplazamiento se conserva y en que la sensación de continuidad no se rompe. Son exactamente los factores que se degradan cuando un contenedor se vacía y se vuelve a llenar, y son también los que ninguna métrica de tiempo de respuesta refleja. Por eso una transición puede ser objetivamente más lenta que un esqueleto, porque no muestra nada nuevo hasta el final, y aun así puntuar mejor en cualquier prueba con personas reales.
Cuando el valor lento nace de un estado que tú controlas, marcar la actualización como transición es lo natural: decides en el origen que ese cambio no es urgente. Cuando el valor lento te llega ya como propiedad desde arriba y no controlas quién lo actualizó, lo que necesitas es diferirlo en el consumidor, es decir, quedarte con el valor anterior mientras el nuevo se procesa. Son la misma idea aplicada en dos puntos distintos de la tubería, y la elección no es de gusto sino de propiedad del estado: si puedes tocar la escritura, marca la transición allí; si solo puedes tocar la lectura, difiere el valor aquí.
// Consumidor que no controla el origen: difiere el valor recibido
// y deduce el estado pendiente comparando lo diferido con lo actual.
function Resultados({ consulta }: { consulta: string }) {
const diferida = useDeferredValue(consulta)
const obsoleta = diferida !== consulta
const items = useResultados(diferida) // la parte cara del arbol
return (
<section aria-busy={obsoleta} data-obsoleta={obsoleta}>
<Lista items={items} />
</section>
)
}
// La senal semantica va en el atributo, no solo en la opacidad:
// un lector de pantalla debe enterarse de que el contenido esta cambiando.
Ese último detalle no es accesorio. Atenuar un bloque comunica la obsolescencia a quien ve la pantalla y no comunica absolutamente nada a quien la escucha, de modo que una técnica pensada para mejorar la percepción puede empeorar la experiencia de una parte de los usuarios si se implementa solo con opacidad. El atributo que declara que una región está ocupada, junto con una región de anuncio cortés que informe del cambio cuando termine, es lo que convierte la transición en una mejora universal y no en una mejora visual. Y como el estado pendiente ya es un dato explícito, exponerlo por esas dos vías no cuesta ningún trabajo adicional: es la misma variable leída dos veces.
Dónde la técnica no ayuda o estorba
Conviene delimitar el alcance con la misma energía con la que se ha defendido la técnica, porque su aplicación indiscriminada produce interfaces peores. El primer límite es que una transición no acelera nada: si el dato tarda tres segundos, seguirá tardando tres segundos, y durante ese tiempo la pantalla mostrará información obsoleta sin decirlo con suficiente claridad. A partir de cierto umbral, alrededor del segundo, el esqueleto honesto comunica mejor que la atenuación, porque nadie mantiene la atención sobre un contenido a medio atenuar durante tanto rato. La técnica está diseñada para el rango que va de los cien a los ochocientos milisegundos, que es donde el parpadeo molesta y la espera todavía no exige explicación.
El caso más problemático aparece al combinar transiciones con la lección primera de este nivel. Si mantienes la pantalla vieja mientras cargas la nueva y además la respuesta que llega es una respuesta obsoleta que no descartaste, el resultado es una interfaz que muestra durante largo rato datos equivocados con aspecto de datos definitivos, sin indicador de carga que avise. La transición amplifica el bug de la carrera lógica en lugar de suavizarlo, porque le quita al usuario la única pista que tenía. La regla es inflexible: el descarte por token es un prerrequisito de la transición, nunca un sustituto ni un complemento opcional.
Existe también un límite de composición que se descubre tarde y duele. Si dentro del subárbol que se está construyendo aparece un componente que suspende y no está cubierto por un límite adecuado, el motor puede verse obligado a mostrar el respaldo de carga más cercano, y entonces la transición degenera precisamente en el parpadeo que quería evitar, con el agravante de que ahora el parpadeo ocurre más tarde y por tanto sorprende más. Colocar los límites de suspensión con criterio deja de ser una cuestión de organización y pasa a ser parte del diseño de la transición: cada límite es una frontera que declara hasta dónde puede mantenerse el contenido antiguo y a partir de dónde se acepta mostrar un hueco.
Bloquear
La interfaz deja de responder hasta que llega el dato. Cero parpadeo y cero confianza: nadie sabe si su pulsación se registró. Es la peor de las tres y sigue siendo común en formularios.
Esqueleto
Se destruye el contenido y se muestra un armazón. Honesto y predecible, pero pierde foco, salta la maquetación y castiga por igual a quien tenía buena conexión.
Transición
Se conserva lo viejo, atenuado, mientras se construye lo nuevo. Continuidad máxima a cambio de mostrar información superada durante un rato acotado.
Criterio
Por debajo de cien milisegundos no hace falta nada. Entre cien y ochocientos, transición. Por encima de un segundo, esqueleto con progreso. Mide antes de elegir.
El segundo límite es la naturaleza de la acción. Una transición sirve para navegar, filtrar y cambiar de vista, es decir, para operaciones de lectura donde el estado anterior sigue siendo una respuesta razonable a la pregunta anterior. No sirve para confirmar una escritura, porque ahí el usuario necesita saber si su acción tuvo efecto y mantener la pantalla previa comunica justo lo contrario. Tampoco sirve cuando el contenido viejo puede inducir a error de forma grave, como en un panel de precios, una dosis o un saldo, donde mostrar un valor superado con apariencia normal es peor que mostrar un hueco. Y no aporta nada cuando el cambio es local y barato, donde toda la maquinaria solo añade latencia de coordinación.
Hay una lectura de esta técnica que la deja en poco más que un truco de presentación, y otra que la convierte en un principio de diseño de sistemas interactivos. La primera dice que sirve para que no parpadee la lista. La segunda parte de una observación más dura: el tiempo de cómputo entre dos fotogramas es un recurso escaso y no divisible, y cada vez que el motor decide en qué gastarlo está resolviendo un problema de planificación idéntico al de un sistema operativo con un solo procesador. Durante veinte años ese problema se resolvió con la política más simple posible, atender todo el trabajo pendiente en el orden en que se generó y hasta el final, que es exactamente lo que hace un planificador sin prioridades ni desalojo, y por eso las interfaces se congelaban cuando alguien pedía algo caro. Marcar una actualización como transición no es pedir que vaya más rápido: es declarar su prioridad, y una declaración de prioridad solo tiene sentido dentro de un planificador capaz de desalojar trabajo empezado. Ahí está el verdadero cambio, y explica por qué la pureza del render dejó de ser una recomendación para convertirse en una condición de funcionamiento, igual que un núcleo apropiativo exige que el estado de una tarea sea salvable y restaurable. Esto conduce a una reformulación del oficio que vale la pena adoptar: al diseñar una pantalla ya no basta con preguntarse qué datos necesita y cómo se pintan, hay que preguntarse también qué actualizaciones de esa pantalla merecen el siguiente fotograma y cuáles pueden esperar tres. La respuesta casi nunca es uniforme, y quien no la escribe explícitamente la está respondiendo igual, solo que por omisión y siempre de la peor manera posible, tratando el filtrado de diez mil filas con la misma urgencia que el parpadeo del cursor.
- Construye una vista con una lista costosa de filtrar y dos versiones: una que vacía el contenedor y muestra esqueleto, y otra que mantiene la lista atenuada durante la transición.
- Con la red limitada a una latencia de cuatrocientos milisegundos, graba ambas y compara salto de maquetación, pérdida de foco y posición del desplazamiento tras cada cambio.
- Añade el umbral de doscientos milisegundos antes de atenuar y comprueba en una conexión rápida que el indicador ya no aparece nunca.
- Introduce a propósito una petición obsoleta sin descarte y observa cuánto tiempo la interfaz muestra datos equivocados con aspecto definitivo. Añade el token y repítelo.
- Sube la latencia a tres segundos y vuelve a comparar las dos versiones. Determina empíricamente el umbral a partir del cual el esqueleto comunica mejor que la atenuación.
- Toma tres actualizaciones de una pantalla real de tu producto y clasifícalas como urgentes o aplazables. Justifica cada clasificación en una frase y aplícala.