wandres.dev
EL FUTURO DE LA REACTIVIDAD · hacia dónde va

Async transparente

Durante veinte años esperar datos fue un caso especial que contaminaba todo el código que lo tocaba: un valor dejaba de ser un valor y se convertía en una tripleta de cargando, error y dato, y cada consumidor tenía que ramificar sobre ella. La dirección que marcan Solid 2.0 y React converge en disolver ese caso especial haciendo que la espera sea una propiedad ambiental del grafo reactivo y no una forma distinta de dato. Esta lección explica el mecanismo —derivaciones que pueden estar pendientes, propagación de la pendencia hasta una frontera declarativa, y transiciones que mantienen la pantalla vieja mientras se prepara la nueva—, lo que cuesta esa transparencia en coherencia, cancelación y depuración, y por qué el problema de fondo no es la asincronía sino la coloración de funciones que arrastra el lenguaje desde el primer callback.

⏱ 18 min

Mira cualquier componente que consuma datos remotos y verás la misma cicatriz. El dato no es un dato: es un objeto con tres campos, y cada consumidor abre un condicional de tres ramas antes de poder decir nada útil. Esa cicatriz se replica hacia arriba, porque un componente que compone dos fuentes asíncronas tiene que combinar dos tripletas, y hacia abajo, porque cualquier derivación de un dato que puede no estar tiene que decidir qué significa derivar de la nada. Nada de eso es esencial al problema. Es el precio de que la espera sea un caso especial en lugar de una propiedad del sistema. La apuesta que están haciendo a la vez Solid y React —desde tradiciones opuestas y con vocabularios distintos— es exactamente la misma: que esperar deje de ser algo sobre lo que ramificas y pase a ser algo que el runtime propaga por ti.

🎯 Al terminar esta lección sabrás
  • Diagnosticar por qué la tripleta de cargando, error y dato contamina a todos los consumidores de un valor remoto.
  • Explicar el mecanismo de la pendencia como propiedad que se propaga por el grafo hasta una frontera declarativa.
  • Distinguir suspender de bloquear, y una transición de una carga, en términos de qué ve el usuario.
  • Reconocer los costes reales de la transparencia: coherencia, cancelación, depuración y latencia percibida.

El caso especial que se propagó a todo el código

Empieza por el síntoma, que conoces de memoria. Una función que pide datos no devuelve el dato: devuelve un contenedor que dice si el dato ya llegó.

// el modelo explicito: el valor y su ausencia viajan juntos
type Remoto<T> =
  | { estado: 'cargando' }
  | { estado: 'error'; error: Error }
  | { estado: 'listo'; dato: T }

const usuario = useQuery(...)   // Remoto<Usuario>
const pedidos = useQuery(...)   // Remoto<Pedido[]>
// para derivar cualquier cosa hay que resolver antes cuatro combinaciones

El problema no es la tripleta en sí, que además es un modelado excelente y lo estudiaste al tipar el estado. El problema es que no se queda quieta. Cada valor derivado de un valor remoto hereda la incertidumbre, y componer dos fuentes multiplica los casos en lugar de sumarlos. Una pantalla con cuatro fuentes tiene, en teoría, ochenta y una combinaciones de estado, y el programador acaba escribiendo un condicional temprano que las colapsa todas en un espinner, precisamente la peor experiencia posible.

Debajo hay un problema más antiguo y más general: la coloración de funciones. Una función que espera algo solo puede ser llamada desde otra función que también espera, y ese color se contagia hacia arriba por toda la pila de llamadas hasta la raíz. La tripleta es la versión de ese contagio en el mundo del estado: en cuanto un valor puede no estar, todo lo que lo lee tiene que saber tratar con su ausencia.

El contagio tiene además un efecto perverso sobre el diseño. Como componer tripletas es incómodo, el programador tiende a resolverlas lo antes posible, en el punto más alto donde tiene todas las fuentes a la vista, y eso concentra la espera en la raíz de la pantalla. El resultado es la peor granularidad posible: un único bloque que espera a lo más lento de todo lo que hay debajo, aunque el noventa por ciento del contenido estuviera listo hace medio segundo.

Conviene notar también que la tripleta es una simplificación que miente por omisión. El estado real de un dato remoto tiene más matices —está fresco, está caducado pero utilizable, se está revalidando en segundo plano, falló pero conservamos la copia anterior— y las librerías de cache maduras acabaron exponiendo todos esos matices como campos adicionales. Cada campo nuevo multiplica otra vez las combinaciones que el consumidor tendría que considerar, y en la práctica nadie las considera.

ℹ️
El estado de carga es accidental, el de error no

Hay que separar dos cosas que la tripleta junta. Que un dato aún no haya llegado es información sobre la red, no sobre el dominio: mañana ese mismo dato puede estar en una réplica local y la espera desaparece sin que cambie ni una regla de negocio. Que una petición haya fallado, en cambio, sí es información del dominio, porque el usuario tiene que enterarse y a veces decidir. Por eso la dirección del ecosistema es disolver el primer caso y conservar el segundo: la pendencia se vuelve ambiental y transparente, mientras que el error sube a una frontera declarativa que decide qué mostrar. Confundirlos lleva a quitar del medio también los errores, y ahí sí se pierde información.

La pendencia como propiedad del grafo

Antes de mirar el mecanismo, conviene fijar el objetivo, porque no es hacer que la espera desaparezca sino que deje de ser un asunto de todos. La espera existe, la red es lenta y a veces falla; lo que se persigue es que esa realidad la conozca únicamente quien tiene algo que decidir al respecto, y no cada función que toca el dato por el camino.

El giro conceptual cabe en una frase: en vez de que el valor lleve encima su incertidumbre, es el grafo el que sabe que una parte de sí mismo aún no está lista. Una derivación puede leer una fuente asíncrona y, mientras esa fuente no haya resuelto, la derivación entera queda marcada como pendiente. Esa marca viaja por las mismas aristas que ya transportaban las invalidaciones, y se detiene en la primera frontera que declara qué hacer con ella.

// modelo de derivacion asincrona: se lee como si fuera sincrona
const usuario = createAsync(() => fetchUsuario(id()))
const pedidos = createAsync(() => fetchPedidos(usuario().id))
const total = () => pedidos().reduce((a, p) => a + p.importe, 0)
// ninguna de las tres ramifica sobre cargando
// la pendencia sube sola hasta la frontera que la maneja

Fíjate en lo que ha desaparecido de ese fragmento: la tripleta, los condicionales de guarda y la pregunta de qué significa sumar sobre datos que no están. La derivación de total se escribe como se escribiría si todo fuese síncrono, porque en el momento en que corre todo es síncrono; simplemente puede no correr todavía.

Ese matiz es el corazón del modelo y merece enunciarse con precisión: la asincronía no ha desaparecido del sistema, ha desaparecido del cuerpo de las funciones. Sigue habiendo una espera, sigue habiendo un instante en que el dato no está, pero ese instante ya no ocurre dentro del código que usa el dato, sino antes de que ese código llegue a ejecutarse. Es exactamente la misma transformación que hizo el recolector de basura con la memoria: el problema sigue existiendo, pero deja de aparecer en cada línea.

flowchart TD
F[fuente remota pendiente] --> D1[derivacion usuario]
D1 --> D2[derivacion pedidos]
D2 --> D3[derivacion total]
D3 --> B[frontera declarativa]
B --> P1[muestra la vista anterior en transicion]
B --> P2[muestra el reserva si no hay nada previo]
B --> P3[deja subir el error al manejador]
style F fill:#f38ba8,color:#11111b
style B fill:#f9e2af,color:#11111b

La pieza que hace habitable ese modelo es la transición, y conviene entender por qué no es un detalle de comodidad. Sin ella, cualquier cambio de una fuente vaciaría la pantalla mientras se recalcula, y una interfaz que parpadea a cada filtro es peor que una que ramifica. Con ella, el runtime mantiene vivo el resultado anterior y prepara el nuevo en paralelo, publicándolo solo cuando está completo; lo que el usuario percibe es una pantalla que se queda quieta y ligeramente atenuada, no una que desaparece.

Esa distinción entre carga y transición es la que más cuesta interiorizar, porque en el modelo explícito no existía: había un único booleano de cargando y daba igual si era la primera vez o la quinta. En el modelo transparente son dos situaciones distintas con dos respuestas distintas. Si no hay nada previo que mostrar, la frontera enseña su contenido de reserva. Si lo hay, no debe enseñarlo nunca, porque sustituir contenido válido por un esqueleto es destruir información que el usuario ya tenía.

React llega al mismo destino desde otra tradición. En vez de marcar nodos de un grafo, deja que la lectura de un recurso no resuelto interrumpa la ejecución del componente y la reanude cuando el dato llegue; el mecanismo interno es distinto, pero el contrato que ve quien escribe el código es idéntico: lees el valor como si estuviera, y la frontera de arriba decide qué se ve mientras no lo está. La convergencia de dos diseños tan lejanos en el mismo contrato es la mejor señal de que el contrato es correcto.

Hay una diferencia práctica que sí importa entre ambos enfoques y conviene no barrerla bajo la alfombra. Un modelo basado en interrumpir y reanudar la ejecución exige que el código que se interrumpe sea seguro de reejecutar, porque puede correr varias veces antes de completarse. Un modelo basado en marcar nodos de un grafo no reejecuta nada que ya se hubiera completado. La primera restricción es sutil, se rompe con facilidad al introducir un efecto secundario donde no tocaba, y explica buena parte de las reglas que a primera vista parecen arbitrarias.

🔮

Leer como si estuviera

El consumidor escribe la lógica del caso feliz. La ausencia deja de ser una rama y pasa a ser la razón por la que ese código aún no se ejecuta.

🚧

Frontera declarativa

Un único punto del árbol declara qué mostrar mientras algo de su interior está pendiente, en vez de repetir esa decisión en cada hoja.

🎞️

Transición sobre carga

Cuando ya había contenido, cambiar de datos no debe vaciar la pantalla: la vista anterior se mantiene viva y marcada como obsoleta hasta que la nueva esté completa.

🧵

Coherencia por lote

Varias fuentes que resuelven en instantes distintos se publican juntas, para que el usuario no vea una pantalla mitad vieja y mitad nueva.

Lo que cuesta que esperar sea transparente

La transparencia no es gratis y conviene decir dónde se paga, porque es exactamente donde fallan las aplicaciones que la adoptan sin entenderla. Hay cinco facturas y ninguna se salda sola.

El primer coste es la coherencia. Un grafo síncrono garantiza que nunca ves una mezcla de valores viejos y nuevos porque recalcula en orden topológico y todo ocurre en el mismo instante. Con ramas asíncronas, ese instante se estira: un diamante con una rama que resuelve en diez milisegundos y otra en dos segundos puede publicar un estado incoherente si el runtime no espera a las dos. La respuesta es agrupar la publicación, y esa agrupación es la parte difícil de implementar y la que distingue una implementación seria de una demostración.

El segundo es la cancelación. Si el usuario cambia de filtro tres veces seguidas, hay tres cálculos en vuelo y solo el último importa; sin cancelación explícita, el segundo puede resolver después del tercero y sobrescribirlo. El grafo tiene la información necesaria para saber que un cálculo quedó obsoleto —su fuente cambió— pero abortar el trabajo en curso requiere que cada operación acepte una señal de aborto y la respete de verdad, incluida la del servidor.

El tercero es la latencia percibida, y es el más contraintuitivo. Hacer la espera transparente facilita tanto encadenar derivaciones asíncronas que se cae con naturalidad en la cascada: pedir el usuario, esperar, pedir sus pedidos con el identificador que acaba de llegar, esperar otra vez. El modelo explícito hacía visible ese encadenamiento y dolía; el transparente lo esconde. La disciplina que lo compensa es empezar las peticiones independientes a la vez y dejar que el grafo espere a ambas.

// cascada accidental: la segunda espera a la primera sin necesitarla
const usuario = createAsync(() => fetchUsuario(id()))
const ajustes = createAsync(() => fetchAjustes(usuario().cuenta))  // encadenado

// version paralela: solo se encadena lo que de verdad depende
const cuenta = createAsync(() => fetchCuenta(id()))
const perfil = createAsync(() => fetchPerfil(id()))   // arranca a la vez

Y hay una consecuencia de diseño que se deriva del tercero: la colocación de las fronteras deja de ser un detalle y pasa a ser una decisión de producto. Una frontera muy alta produce pantallas que aparecen enteras y tarde; muchas fronteras bajas producen pantallas que aparecen por partes y pronto, a costa de un aterrizaje más ruidoso. No hay respuesta correcta universal, hay una respuesta correcta por pantalla, y decidirla exige saber qué parte del contenido es el motivo por el que el usuario entró.

El cuarto es la depuración. Un fallo de propagación síncrona se lee en una pila de llamadas; una espera que no termina, no. Cuando la pendencia es ambiental, la pregunta de por qué esta pantalla lleva tres segundos en su estado de reserva requiere ver el grafo y saber qué nodo sigue sin resolver, que es justamente el tipo de herramienta que solo puede existir bien cuando el grafo está estandarizado.

El quinto se paga en el sistema de tipos y rara vez se menciona. En el modelo explícito, mirar la firma de un valor te decía si podía no estar. En el transparente, la firma dice que hay un dato y la posibilidad de esperar se ha mudado al contexto de ejecución, donde ninguna anotación la recoge. Se gana ergonomía y se pierde una garantía estática, y eso obliga a compensarlo con convenciones muy claras sobre qué funciones pueden suspender y cuáles no.

La suma de los cinco costes tiene una lectura común: la transparencia no elimina la complejidad de la asincronía, la traslada del código de aplicación al diseño del grafo y de las fronteras. Es un buen negocio, porque en el nuevo sitio la complejidad está concentrada, es visible y la manejan pocas personas, en vez de estar repartida en cada hoja del árbol. Pero es un traslado, y quien lo adopta creyendo que es una eliminación acaba con los mismos problemas en un lugar donde ya no sabe buscarlos.

💡
Mide el tiempo hasta lo que importa, no hasta lo último

La métrica que engaña en este modelo es el tiempo total hasta que todo está listo, porque premia a la frontera alta que espera a la petición más lenta. La métrica útil es el tiempo hasta que el usuario puede hacer aquello por lo que vino: leer el primer párrafo, ver el precio, escribir en el campo. Instrumenta esa marca concreta antes y después de mover una frontera, y descubrirás que muchas reorganizaciones que parecen mejoras porque reducen el número de esperas visibles en realidad retrasan lo único que se estaba midiendo mal.

⚠️
Transparente no significa invisible

Que el código no ramifique sobre la espera no autoriza a olvidarse de que hay una red por debajo. Las tres patologías clásicas siguen ahí y se vuelven más fáciles de introducir: la cascada de peticiones encadenadas que podían ser paralelas, la petición obsoleta que resuelve tarde y pisa el resultado bueno, y la frontera colocada tan arriba que un dato secundario vacía media pantalla. La transparencia mueve estos problemas del código de aplicación al diseño del grafo y de las fronteras; no los elimina. Quien la adopta sin medir termina con una aplicación que se lee muy bien y se siente mucho peor que la anterior.

La asincronía nunca fue el problema; la coloración lo era

Merece la pena ver esto con la perspectiva larga, porque es la misma historia contada tres veces. Primero fueron los callbacks: esperar obligaba a partir la función en dos y a invertir el control, y el precio se llamó pirámide. Después llegaron los promises y las funciones asíncronas, que devolvieron la linealidad al código pero cobraron otro peaje, el color: una función que espera solo puede ser llamada por otra que espera, y ese color trepa por la pila hasta la raíz contaminando a funciones que no tenían ningún interés en la red. La tripleta de cargando, error y dato es ese mismo color trasladado al mundo de los valores; por eso se contagia igual y por eso multiplica casos al componer. Lo que están haciendo Solid y React no es inventar una forma más de esperar, es descolorear. La pendencia deja de ser un rasgo del tipo, que obliga a todos los que lo tocan a saber de ella, y pasa a ser un rasgo del contexto de ejecución, que solo conoce quien decide qué se ve mientras tanto. La consecuencia para el diseño de estado es concreta y sobrevive a cualquier librería: cuando modelas datos remotos, distingue lo que es información sobre el dominio de lo que es información sobre el transporte. El error, la caducidad y el permiso son dominio y merecen estar en tu modelo. El aún no llegó es transporte, y todo el ecosistema lleva veinte años intentando sacarlo de ahí. Cada vez que veas un tipo de dominio con un campo de carga dentro, estás mirando una capa de transporte filtrada donde no debía, y eso seguirá siendo un error de modelado el día que ninguna de estas librerías exista.

⚔️ Descolorea una pantalla real
  1. Toma una pantalla tuya con tres fuentes remotas y cuenta cuántas combinaciones de estado maneja de verdad frente a cuántas colapsa en un espinner único.
  2. Reescríbela con derivaciones que se lean como síncronas y una sola frontera declarativa, y compara la cantidad de condicionales antes y después.
  3. Identifica en tu código una cascada de peticiones encadenadas que no dependen realmente entre sí y paralelízalas sin tocar la vista.
  4. Provoca a mano una petición obsoleta cambiando un filtro dos veces rápido y comprueba si tu implementación descarta la respuesta tardía o la publica.
  5. Construye un diamante con una rama rápida y otra lenta y verifica si tu runtime publica una vez o dos; describe qué vería el usuario en cada caso.
  6. Argumenta, para cada campo de uno de tus tipos remotos, si es información de dominio o de transporte, y saca del tipo todo lo segundo.