wandres.dev
JOTAI A FONDO · estado atómico

Async y Suspense: la promesa como nodo del grafo

En Jotai la asincronía no es un caso especial con middleware propio: un átomo cuya función de lectura devuelve una promesa participa en el grafo igual que cualquier otro, y React la consume mediante Suspense. Esta lección explica la reducción conceptual que eso supone frente a thunks y sagas, dónde colocar los límites de Suspense para gobernar la granularidad de la carga, cómo evitar el parpadeo con valores obsoletos y loadable, y por qué la escritura asíncrona sigue siendo territorio del átomo de acción.

⏱ 20 min

En Redux, un dato remoto exigía maquinaria: un middleware que interceptase acciones, una máquina de estados de petición escrita a mano con sus banderas de carga y error, y una disciplina para que ninguna vista leyera datos a medio llegar. Jotai hace una jugada distinta y más económica: no añade nada. Un átomo cuya función de lectura devuelve una promesa es un átomo normal cuyo valor resulta ser una promesa, y el resto lo pone React, que sabe suspender un componente hasta que una promesa leída durante el renderizado resuelva. La consecuencia es una reducción conceptual notable: un solo mecanismo cubre estado síncrono, derivado y asíncrono, y las tres formas se componen entre sí sin adaptadores. Lo que esa uniformidad no elimina son las decisiones de experiencia, que se trasladan intactas al lugar donde se colocan los límites de suspensión.

🎯 Al terminar esta lección sabrás
  • Declarar átomos asíncronos y entender por qué participan en el grafo sin tratamiento especial.
  • Colocar límites de Suspense para gobernar la granularidad de la carga y evitar cascadas de espera.
  • Evitar el parpadeo de recarga con valores obsoletos y convertir la suspensión en datos con loadable.
  • Distinguir la lectura asíncrona del átomo de acción asíncrono y reconocer el problema de la cancelación.

La promesa como valor cualquiera

Un átomo asíncrono se declara igual que uno derivado, con la única diferencia de que su función de lectura es asíncrona. Lo relevante no es la sintaxis sino la integración: ese átomo puede leer otros átomos, otros átomos pueden leerlo a él, y la cadena de dependencias funciona sin distinción entre nodos síncronos y asíncronos.

import { atom } from 'jotai'

const usuarioIdAtom = atom('u-1')

const usuarioAtom = atom(async (get) => {
  const id = get(usuarioIdAtom)
  const res = await fetch(`/api/usuarios/${id}`)
  return res.json()
})

// un derivado sincrono puede leer un atomo async: get devuelve el valor ya resuelto
const nombreAtom = atom((get) => get(usuarioAtom).nombre)

Ese último detalle es el que hace que el modelo sea más que azúcar sintáctico. Dentro de un derivado, leer un átomo asíncrono devuelve el valor resuelto, no la promesa, porque el sistema espera por él antes de evaluar el cuerpo. La composición de datos remotos con datos locales deja de requerir la coreografía habitual de comprobar si el dato ya llegó antes de usarlo: la ausencia de dato se expresa como suspensión, no como una rama condicional repetida en cada consumidor.

Conviene precisar el mecanismo, porque la palabra espera induce a error. El átomo no bloquea nada: devuelve la promesa, y es React quien, al encontrarla durante el renderizado, interrumpe ese componente y lo reintenta cuando resuelve. El grafo, mientras tanto, sigue siendo síncrono en su propagación; lo asíncrono es el valor que transporta uno de sus nodos. Esta distinción explica por qué la asincronía no exigió inventar un tipo nuevo de átomo ni un canal paralelo de notificación: bastó con que el sistema supiera desenvolver una promesa al leerla desde otro derivado.

La reevaluación funciona igual que en el caso síncrono. Si usuarioIdAtom cambia, el átomo asíncrono se recomputa, lo que significa lanzar una petición nueva, y todos sus dependientes se recomputan en cascada cuando esa petición resuelve. La invalidación manual —el problema que las librerías de datos resuelven con claves y etiquetas— aquí es simplemente una arista del grafo.

flowchart TD
ID[usuarioIdAtom] --> U[usuarioAtom async]
U --> N[nombreAtom]
U --> P[permisosAtom]
N --> V[cabecera]
P --> W[menu lateral]
style U fill:#f9e2af,color:#11111b
style V fill:#a6e3a1,color:#11111b

Dónde poner el límite de suspensión

Un componente que lee un átomo asíncrono no resuelto suspende, y el Suspense ancestro más cercano muestra su contenido de reserva. Esto convierte la colocación de esos límites en la decisión de diseño central de toda la lección, porque determina qué porción de la interfaz desaparece mientras se espera. Un único límite en la raíz produce el peor resultado posible: la aplicación entera se sustituye por un cargador cada vez que cualquier dato se pide.

import { Suspense } from 'react'

function Panel() {
  return (
    <>
      <Suspense fallback={<CabeceraEsqueleto />}>
        <Cabecera />
      </Suspense>
      <Suspense fallback={<ListaEsqueleto />}>
        <Lista />
      </Suspense>
    </>
  )
}

Con límites independientes, la cabecera y la lista se resuelven por separado y cada región muestra su propio esqueleto. Existe además una trampa recurrente: si un componente lee un átomo asíncrono y solo entonces renderiza a un hijo que lee otro, las dos peticiones se encadenan en serie aunque fuesen independientes. Esa cascada no la causa Jotai sino la estructura del árbol, y se resuelve leyendo ambos átomos al mismo nivel o combinándolos en un derivado que los pida en paralelo.

💡
Suspense necesita compañía

Un límite de suspensión gobierna el estado de carga, pero no dice nada del fallo. Una promesa rechazada dentro de un átomo se propaga como una excepción durante el renderizado y necesita un límite de error que la capture. La pareja de límites —suspensión para lo que aún no llegó, error para lo que no llegará— es lo que sustituye por completo a la máquina de estados de petición escrita a mano, y omitir la mitad deja la aplicación en blanco ante el primer fallo de red.

Recarga sin parpadeo y suspensión como dato

El modelo tiene un comportamiento incómodo por defecto: cuando una dependencia cambia y el átomo asíncrono se relanza, el consumidor vuelve a suspender y el contenido ya visible desaparece. Cambiar de usuario hace que la ficha entera parpadee en lugar de actualizarse. La solución idiomática es conservar el valor anterior mientras el nuevo se resuelve, de modo que la transición sea una actualización y no una reaparición.

import { atom } from 'jotai'
import { loadable, unwrap } from 'jotai/utils'

// conserva el valor previo durante la recarga: sin parpadeo entre usuarios
const usuarioEstableAtom = unwrap(usuarioAtom, (previo) => previo)

// convierte la suspension en datos: estado explicito en lugar de excepcion
const usuarioCargableAtom = loadable(usuarioAtom)

Las dos utilidades resuelven necesidades distintas y conviene no confundirlas. La primera mantiene visible el dato anterior y evita el parpadeo sin renunciar al modelo declarativo. La segunda renuncia deliberadamente a la suspensión y devuelve un objeto con un estado explícito de carga, error o éxito, lo que resulta útil cuando se necesita mostrar un indicador en línea junto al contenido antiguo, o cuando el componente que lee está fuera de cualquier límite de suspensión y no debe derribar el árbol.

📝
Async no convierte a Jotai en librería de datos

Los átomos asíncronos cubren con elegancia la lectura puntual y su entrelazado con estado de cliente, pero no traen revalidación en foco, reintentos con retroceso, deduplicación de peticiones concurrentes, paginación ni caché con desalojo. Para estado de servidor serio la herramienta adecuada sigue siendo una librería dedicada, y el patrón dominante consiste en dejar que ella gobierne el dato remoto y usar átomos para el estado de cliente que se deriva de él. Confundir ambos papeles lleva a reimplementar a mano una caché de red, que es exactamente el trabajo que el track lleva quince niveles enseñando a no repetir.

La escritura asíncrona es otra cosa

Es frecuente asumir que si la lectura asíncrona se resuelve sola, la escritura también. No es así, y la distinción importa. Una operación que envía datos al servidor no es una derivación sino una intención con efectos, y su lugar natural es el átomo de acción, cuya función escritora puede ser asíncrona y coordinar tanto la petición como la actualización de los átomos afectados.

const guardandoAtom = atom(false)

const guardarUsuarioAtom = atom(null, async (get, set, datos: Datos) => {
  set(guardandoAtom, true)
  try {
    const res = await fetch('/api/usuarios', { method: 'POST', body: JSON.stringify(datos) })
    set(usuarioIdAtom, (await res.json()).id)   // dispara la recarga del grafo
  } finally {
    set(guardandoAtom, false)
  }
})
⚛️

Lectura asíncrona

Una función de lectura que devuelve una promesa. Participa en el grafo y se relanza sola cuando cambia una dependencia.

Límite de suspensión

Decide qué porción de la pantalla desaparece durante la espera. Es una decisión de experiencia, no de rendimiento.

🫧

Valor previo

Conservar lo ya mostrado durante la recarga convierte un parpadeo en una actualización perceptible como continua.

📦

Suspensión como dato

Cuando el indicador debe convivir con el contenido, el estado explícito de carga vuelve al modelo de forma deliberada.

Queda una arista incómoda que conviene nombrar sin adornos: la cancelación. Si una dependencia cambia dos veces en rápida sucesión, se lanzan dos peticiones y nada garantiza que resuelvan en orden. El sistema descarta el resultado de la evaluación obsoleta, pero la petición en vuelo sigue consumiendo red y no se aborta sola. Cuando esto importa —búsquedas mientras se teclea, filtros agresivos— hay que llevar la cancelación a mano con una señal de aborto, y ese es precisamente el tipo de trabajo que una librería de datos dedicada ya tiene resuelto.

El estado de carga no es estado: es la ausencia de un valor

Durante una década el frontend modeló la espera como si fuera información del dominio. Cada recurso remoto arrastraba tres campos —cargando, error, datos— que el programador declaraba, sincronizaba y comprobaba en cada consumidor, y esa triplicación producía una geometría de estados imposibles que nadie sabía cerrar: cargando y con error a la vez, con datos y cargando, con error pero con datos viejos que ya no corresponden a la consulta actual. El número de combinaciones crecía exponencialmente con el número de recursos, y la mayoría no significaban nada. Suspense propone una inversión que es más profunda de lo que su ergonomía sugiere: la espera no es un valor que el dominio deba conocer, sino una propiedad del acto de leer, y por tanto pertenece al mecanismo de renderizado y no al modelo. Cuando la ausencia de un dato se expresa como suspensión, los estados imposibles dejan de ser representables porque el componente que lee o tiene el valor o no llega a ejecutarse; no hay una tercera situación que codificar ni una rama que olvidar. Ese es el mismo principio que ha guiado todo el track cuando pedía minimizar el estado real: cada campo que se elimina del modelo no es solo una línea menos, es un conjunto entero de combinaciones que dejan de existir y que nadie tendrá que probar, documentar ni depurar. La madurez consiste en reconocer qué preguntas pertenecen al dominio y cuáles al mecanismo. Si el usuario ya llegó o no es una cuestión del mecanismo. Qué puede hacer ese usuario es del dominio. Mezclarlas fue el error de una generación de código, y separarlas es lo que hace que un componente pueda escribirse suponiendo que sus datos existen, que es la única suposición bajo la cual la lógica de negocio se lee con claridad.

⚔️ Gobierna la espera en lugar de sufrirla
  1. Convierte una carga de datos que hoy gestionas con banderas manuales en un átomo asíncrono y elimina todas las comprobaciones de carga de los consumidores. Cuenta las ramas que desaparecen.
  2. Coloca un único límite de suspensión en la raíz y observa el resultado. Después descompón la interfaz en límites por región y compara ambas experiencias con la red ralentizada.
  3. Provoca deliberadamente una cascada anidando dos componentes que leen átomos asíncronos independientes. Mide el tiempo total, reestructura para que se pidan en paralelo y vuelve a medir.
  4. Cambia una dependencia de un átomo asíncrono y observa el parpadeo. Aplica la conservación del valor previo y describe exactamente qué cambió en la percepción.
  5. Sustituye un consumidor que suspende por uno que lee la versión con estado explícito y muestra un indicador en línea. Argumenta en qué situaciones cada opción es preferible.
  6. Escribe un átomo de acción asíncrono que envíe datos y provoque la recarga del grafo. Después lánzalo dos veces seguidas y comprueba si el resultado obsoleto puede ganar la carrera; añade la cancelación si tu caso lo exige.