wandres.dev
ESTADO DEL SERVIDOR · TanStack Query, SWR

TanStack Query: useQuery, claves y estados

Si el estado de servidor es una cache, TanStack Query es la cache hecha librería y useQuery su puerta de entrada. Esta lección disecciona la llamada: la queryKey como identidad y dirección del dato en la cache, el par staleTime y gcTime como dos ejes ortogonales que casi todo el mundo confunde (frescura frente a retención), y la máquina de estados status más fetchStatus que convierte la carga en algo honesto en vez de un spinner que lo tapa todo.

⏱ 17 min

Si la lección anterior te convenció de que el estado de servidor es una cache, TanStack Query es la cache hecha librería, y useQuery es su puerta de entrada. En una sola llamada declaras qué dato quieres, con qué identidad vive en la cache y cuánto tiempo confías en él; a cambio recibes, ya resueltos, los estados de carga, error y éxito que a mano costaban un useEffect frágil y tres useState. Esta lección disecciona esa llamada: la queryKey como identidad, el par staleTime y gcTime como dos ejes que casi todo el mundo confunde, y la pequeña máquina de estados que convierte la carga en algo honesto en vez de un spinner tapándolo todo.

🎯 Al terminar esta lección sabrás
  • Declarar una lectura con useQuery y leer su objeto de retorno.
  • Entender la queryKey como la identidad de un dato en la cache.
  • Separar staleTime (frescura) de gcTime (retención) como dos ejes independientes.
  • Distinguir status de fetchStatus para pintar una carga honesta.

useQuery: declarar, no orquestar

La diferencia entre useQuery y un fetch dentro de useEffect no es de comodidad, es de paradigma. Con useEffect orquestas: describes cuándo pedir, dónde guardar, cómo limpiar, qué hacer si el componente se desmonta a mitad de la petición.

Con useQuery declaras: dices qué dato quieres y bajo qué clave, y delegas toda la orquestación a una cache que ya sabe hacerla bien.

import { useQuery } from "@tanstack/react-query";

function Perfil({ id }: { id: string }) {
  const { data, status, fetchStatus } = useQuery({
    queryKey: ["usuario", id],
    queryFn: () => fetch(`/api/usuario/${id}`).then((r) => r.json()),
    staleTime: 60_000,
  });

  if (status === "pending") return <Spinner />;
  if (status === "error") return <ErrorBox />;
  return <h1>{data.nombre}</h1>;
}

queryFn es la única parte imperativa que queda, y es pura promesa: pide el dato y devuélvelo, o lanza si falla. Todo lo demás —cuándo llamarla, si deduplicar, cuándo reintentar, cuándo revalidar— es decisión de la cache guiada por lo que declaraste. Has pasado de escribir el cómo a describir el qué.

El objeto que devuelve useQuery reúne, ya resuelto, todo lo que la UI necesita para pintar cualquier caso sin un solo useEffect.

📦

data

El dato cacheado cuando existe. Puede estar fresco o stale; la cache lo sirve igual y decide aparte si revalidarlo.

🚦

status

¿Tengo datos? Vale pending, error o success. Es el eje de si hay contenido que mostrar.

🔄

fetchStatus

¿Estoy pidiendo? Vale fetching, paused o idle. Es el eje de la actividad de red, independiente del anterior.

🧯

error

El objeto lanzado por queryFn si la petición falló, disponible sin envolver cada llamada en un try y un catch.

La queryKey: identidad y dirección

La queryKey es lo más importante y lo peor entendido de la librería. No es una etiqueta decorativa: es la dirección del dato en la cache y, a la vez, su identidad. Dos componentes con la misma clave comparten una entrada y una sola petición —eso es la deduplicación—; dos claves distintas son dos datos distintos aunque apunten al mismo endpoint.

La clave es un array, y su forma importa. Se compara por igualdad estructural, no por referencia, así que ["usuario", id] escrito en dos ficheros distintos es la misma clave siempre que id coincida. No necesitas un registro central de claves: la estructura es el registro.

Esa forma de array habilita una jerarquía, y la relación de prefijo entre claves no es un detalle estético: es lo que la invalidación aprovechará en la próxima lección para tirar familias enteras de lecturas de una sola vez.

["usuarios"]                       // toda la coleccion
["usuarios", { estado: "activo" }] // una vista filtrada
["usuario", id]                    // un elemento
["usuario", id, "pedidos"]         // una relacion del elemento
💡
Diseña claves serializables y jerárquicas

Una buena queryKey cumple dos reglas. Es serializable: solo primitivos y objetos planos, nada de funciones ni instancias, porque la cache la usa como identidad estable y necesita compararla sin ambigüedad. Y es jerárquica, de lo general a lo específico: primero el recurso, luego los parámetros, luego las relaciones. Esa jerarquía no es cosmética; es lo que te dejará invalidar ["usuario", id] sin tocar el resto, o al revés tirar toda la familia con solo el prefijo común. Diseñar claves es a esta librería lo que diseñar el esquema es a una base de datos.

staleTime y gcTime: dos relojes distintos

Aquí vive la confusión más cara de la librería, y deshacerla es media maestría. staleTime y gcTime miden cosas distintas con relojes distintos, y quien los ve como el mismo número pelea con la cache el resto del proyecto.

staleTime es frescura: cuánto tiempo una copia se considera fresca y, por tanto, no se revalida. Mientras un dato está fresco, la cache lo sirve de memoria sin tocar la red. Pasado ese tiempo pasa a stale —obsoleto—, lo que no lo borra ni lo oculta: solo lo marca como candidato a revalidarse la próxima vez que algo lo dispare.

gcTime es retención: cuánto sobrevive una entrada en memoria después de que su último observador desaparezca. Es un temporizador de recolección de basura, no de frescura. Un dato puede estar obsoleto y seguir retenido, listo para mostrarse al instante mientras se revalida por detrás, y ese es precisamente el comportamiento estrella de la cache: enseñar lo viejo sin demora y cambiarlo por lo nuevo sin brusquedad.

flowchart LR
F[fresco] -->|pasa staleTime| S[stale]
S -->|un disparador| R[revalidando]
R -->|llega dato| F
F -->|sin observadores| I[inactivo]
S -->|sin observadores| I
I -->|pasa gcTime| G[recolectado]
style F fill:#a6e3a1,color:#11111b
style S fill:#fab387,color:#11111b
style R fill:#89b4fa,color:#11111b
style G fill:#f38ba8,color:#11111b

Los estados: status y fetchStatus

La carga honesta no se pinta con una sola bandera, y por eso la librería expone dos ejes ortogonales. status responde a la pregunta tengo datos: vale pending cuando no hay nada aún, error si la última petición falló sin datos previos, y success cuando hay datos. fetchStatus responde a otra pregunta distinta, estoy pidiendo ahora mismo: vale fetching, paused o idle, con independencia de si ya tengo datos.

Que sean dos ejes y no uno es lo que permite el caso que un booleano solo no captura: status en success y fetchStatus en fetching a la vez, es decir, tengo datos viejos en pantalla mientras traigo los nuevos por detrás. Ese estado es la revalidación en segundo plano, y pintarlo bien es la diferencia entre una UI que respeta al usuario y una que parpadea en cada refresco.

function ListaUsuarios() {
  const { data, status, fetchStatus } = useQuery({
    queryKey: ["usuarios"],
    queryFn: traerUsuarios,
  });

  if (status === "pending") return <Spinner />;   // vacio inicial: no hay nada
  if (status === "error") return <ErrorBox />;

  return (
    <div>
      {fetchStatus === "fetching" && <Barra />}    {/* senal discreta de fondo */}
      <ul>{data.map((u) => <li key={u.id}>{u.nombre}</li>)}</ul>
    </div>
  );
}
📝
Las banderas derivadas son azúcar del mismo eje

La librería ofrece atajos booleanos —isPending, isError, isSuccess, isFetching— que muchos usan sin saber de dónde salen. No son un tercer concepto: son proyecciones de los dos ejes. isPending es status === 'pending'; isFetching es fetchStatus === 'fetching'. Usarlos está bien, pero razonar con los dos ejes de fondo evita el error clásico de tratar la carga como si una sola bandera cubriera toda recarga cuando en realidad hay dos preguntas independientes debajo.

La librería no gestiona peticiones: gestiona un espacio de nombres

Cuando alguien domina TanStack Query de verdad, lo notas en un detalle: piensa en claves, no en peticiones. El principiante ve useQuery como una forma bonita de hacer fetch; el que entendió la librería ve la queryKey como el esquema de un espacio de nombres compartido, una base de datos en miniatura que vive en el cliente y cuya clave primaria diseñas tú. Esa mirada lo reordena todo. La deduplicación deja de ser magia: dos componentes coinciden porque nombraron el mismo dato con la misma clave, igual que dos consultas SQL tocan la misma fila porque usan la misma clave primaria. La revalidación deja de ser un efecto misterioso: es la cache preguntándose, entrada por entrada, si el reloj de la frescura ya venció. Y staleTime frente a gcTime deja de confundirse en cuanto los ves como lo que son: dos relojes sobre la misma entrada, uno que mide cuánto la crees y otro cuánto la guardas, ejes ortogonales que solo se enredan si insistes en verlos como el mismo número. Diseñar bien tus claves —jerárquicas, serializables, tan específicas como el dato y tan generales como la familia que querrás invalidar junta— es la habilidad que separa configurar una cache de pelearse con ella el resto del proyecto. La librería no gestiona peticiones; gestiona un espacio de nombres de datos remotos, y useQuery es solo cómo lees una celda de ese espacio.

⚔️ Piensa en claves, no en peticiones
  1. Convierte una carga hecha con useEffect y useState en un useQuery, y cuenta las líneas que desaparecen.
  2. Diseña la jerarquía de queryKey de un recurso tuyo: colección, vista filtrada, elemento y relación.
  3. Pon staleTime en cero en una query y en un minuto en otra, y observa en la pestaña de red cuándo cada una revalida al reenfocar.
  4. Pinta la revalidación de fondo: usa status para el vacío inicial y fetchStatus para la señal discreta sobre datos ya visibles.
  5. Explica con tus palabras, sin mirar, la diferencia entre staleTime y gcTime y por qué son dos relojes.
  6. Fuerza dos componentes lejanos a usar la misma queryKey y comprueba en la red que solo sale una petición.