Saltar al contenido

Los estados del resource: loading, error, latest y state

Un resource es más que su valor: el accessor lleva su ciclo de vida encima en cuatro propiedades reactivas. loading dice si hay una petición en vuelo; error captura el rechazo del fetcher; state condensa cinco fases —unresolved, pending, ready, refreshing y errored— con más resolución que un booleano; y latest devuelve el último valor conocido sin suspender, la clave para interfaces sin parpadeo mientras una recarga viaja. Leerlas reactivamente en el JSX es modelar la carga como un pequeño autómata declarativo.

El accessor lleva su ciclo de vida encima

El accessor que devuelve createResource no es una función pelada: es una función con propiedades adosadas. Junto al valor, expone loading, error, latest y state, y cada una es reactiva —leerlas crea una suscripción, igual que llamar al accessor—.

TSX
const [usuario] = createResource(id, traerUsuario);usuario();          // el valor: Usuario | undefinedusuario.loading;    // boolean: hay una peticion en vuelousuario.error;      // el error si el fetcher rechazo, undefined si nousuario.latest;     // el ultimo valor conocido, sin suspenderusuario.state;      // "unresolved" | "pending" | "ready" | "refreshing" | "errored"

Que sean reactivas es lo que las hace útiles: no son una foto del momento, sino señales vivas que el grafo actualiza en cada transición del ciclo. Puedes cablearlas directamente al JSX y dejar que la interfaz siga el estado de la petición sin que tú muevas un dedo.

Un apunte de grano fino: como cada propiedad se rastrea por separado, un componente que solo lee usuario.loading se re-evalúa cuando cambia la carga, pero no cuando cambia el valor, y a la inversa. No pagas por leer más de lo que usas. Esa precisión, heredada de todo el modelo reactivo de Solid, hace que exponer cuatro facetas en un mismo accessor no tenga coste oculto: cada consumidor se suscribe solo a la que le importa.

Una nota sobre error: su tipo es unknown —o any según tu configuración—, porque un fetcher puede rechazar con cualquier cosa, no solo con un Error. Trátalo con la misma cautela que un catch: estrecha su tipo antes de leer un .message, y no des por hecha la forma de algo que vino de la red y podría ser una cadena, un objeto o nada reconocible.

TSX
function Ficha() {  const [usuario] = createResource(id, traerUsuario);  return (    <Switch fallback={<Perfil datos={usuario()!} />}>      <Match when={usuario.loading}>        <Spinner />      </Match>      <Match when={usuario.error}>        <Aviso mensaje="No se pudo cargar" detalle={usuario.error} />      </Match>    </Switch>  );}

Fíjate en lo que no hay: ni un isLoading declarado a mano, ni un error que sincronizar en un catch, ni un efecto que ate uno a otro. Lees las propiedades del resource directamente en el Switch, y la interfaz sigue el ciclo de vida de la petición porque esas propiedades son señales vivas. El componente deja de gestionar el estado de carga y pasa a ser una proyección de él: una función del estado del resource al árbol que se ve.

Los cinco estados de state

loading y error cuentan lo esencial, pero state cuenta lo mismo con más resolución: un solo valor que nombra la fase exacta del ciclo.

  • unresolved — no ha habido petición aún; la fuente es falsy. El valor es undefined.
  • pending — primera carga en vuelo, todavía sin ningún valor que mostrar. El valor es undefined.
  • ready — la promesa resolvió; hay valor.
  • refreshing — hay un valor previo y se está recargando (por refetch o por cambio de fuente).
  • errored — el fetcher rechazó; error está poblado.

La distinción que más rendimiento te dará es la que hay entre pending y refreshing. En ambas loading vale true —hay una petición viajando—, pero pending significa «cargando y no tengo nada que enseñar», mientras que refreshing significa «cargando, pero conservo el valor anterior». Esa diferencia es la que te deja elegir entre un spinner a pantalla completa y un refresco discreto que no borra lo que el usuario ya está viendo.

Con este mapa, loading deja de ser un misterio: es exactamente la disyunción de los dos estados en vuelo, y podrías derivarlo tú mismo a partir de state.

TSX
// loading es true exactamente cuando state es "pending" o "refreshing"const cargando = () => usuario.state === "pending" || usuario.state === "refreshing";

Como state es un tipo unión de cinco literales, encaja de maravilla con un Switch de ramas exclusivas: escribes un Match por fase relevante y TypeScript te ayuda a no olvidar ninguna. La diferencia con encadenar loading y error sueltos es que aquí modelas la interfaz como una función total del estado —a cada fase, una vista— en vez de como una secuencia de condiciones que podrían solaparse. Cuando el diseño de tu UI de carga distingue de verdad entre «primera vez» y «refrescando», leer state en lugar de loading es lo que te da esa resolución sin variables auxiliares.

En la práctica, state es además la propiedad que más miras al depurar. Registrar resource.state en un efecto te da una traza legible de cómo evolucionó una carga —unresolved, pending, ready, refreshing— mucho más clara que inferirla del baile de dos booleanos. Es el nombre que el propio resource le pone a su fase, y leerlo equivale a preguntarle directamente en qué punto de su vida se encuentra.

latest: el valor de ayer sin parpadeo

Aquí hay una diferencia sutil pero central entre llamar al accessor, usuario(), y leer usuario.latest. Bajo un límite de Suspense —o dentro de una transición—, leer usuario() mientras el resource está pending o refreshing suspende: dispara el fallback. latest, en cambio, nunca suspende: devuelve el último valor resuelto que hubo, o undefined si nunca lo hubo.

Esa propiedad la hace idiomática para búsquedas y paginación. latest te deja mantener a la vista el resultado anterior —atenuado, si quieres— mientras la nueva petición viaja, y sustituirlo solo cuando de verdad hay algo nuevo. El usuario nunca ve un hueco en blanco; ve una transición suave de un dato al siguiente.

TSX
function Resultados() {  const [q, setQ] = createSignal("");  const [resultados] = createResource(q, buscar);  // 'latest' mantiene la lista anterior visible mientras llega la nueva busqueda  return (    <ul classList={{ atenuado: resultados.loading }}>      <For each={resultados.latest ?? []}>        {(r) => <li>{r.titulo}</li>}      </For>    </ul>  );}

La regla práctica: llama a usuario() cuando quieras que el JSX espere el dato nuevo (con Suspense o Show detrás), y lee usuario.latest cuando prefieras seguir mostrando el dato viejo hasta que el nuevo esté listo. La elección entre uno y otro es, en el fondo, una decisión de experiencia de usuario codificada en qué propiedad lees.

Hay una razón técnica detrás de esta diferencia que conviene nombrar. Leer el valor con usuario() participa en el mecanismo de suspensión: es la lectura la que le dice al Suspense que hay un dato pendiente y que debe mostrar su fallback. latest, por diseño, se abstiene de esa participación —lee sin señalar pendencia—, y por eso puede convivir con contenido ya renderizado sin colapsarlo. No es que una propiedad sea «mejor» que la otra: son dos contratos distintos con el sistema de carga, y elegir bien entre ellos es la diferencia entre una interfaz que parpadea a cada recarga y una que transiciona con suavidad. Este mismo eje —esperar frente a conservar— reaparecerá amplificado cuando estudiemos las transiciones, que generalizan el comportamiento de latest a árboles enteros.

Conviene, eso sí, no idealizar latest. Mostrar datos viejos mientras llegan los nuevos es deseable en una búsqueda, pero engañoso en un panel financiero donde un número obsoleto puede inducir a una decisión equivocada. La elección entre usuario() y usuario.latest es, por eso, tanto de producto como técnica —cuánta obsolescencia tolera esta pantalla en concreto— y merece pensarse caso por caso, en lugar de adoptar una por costumbre.