Módulo 19: Datos con TanStack Query

Objetivos del módulo

  • Distinguir el estado de servidor del estado de cliente y entender por qué piden herramientas distintas
  • Ver qué problemas deja sin resolver un useEffect con fetch
  • Leer datos con useQuery y controlar sus estados con status y fetchStatus
  • Entender la caché por dentro: staleTime, gcTime, recargas automáticas, reintentos y deduplicación
  • Diseñar claves de consulta jerárquicas y reutilizar opciones con queryOptions
  • Encadenar consultas dependientes con skipToken y lanzar consultas en paralelo con useQueries
  • Escribir datos con useMutation, invalidar la caché y tratar los errores de la API
  • Paginar sin parpadeos, precargar con queryClient.query y hacer listas infinitas con useInfiniteQuery
  • Hacer actualizaciones optimistas que se deshacen solas si el servidor falla
  • Inspeccionar la caché con las DevTools
  • Integrar Query con Suspense, Error Boundaries y los loader de React Router
  • Configurar el cliente: valores globales, errores centralizados y tipos

💡 Consejo: TanStack Query 5 admite react ^18 || ^19, y casi todo este módulo funciona igual en React 18.3.1: la lista, la paginación, la mutación optimista y el ejemplo de Suspense se han ejecutado también con esa versión, con los mismos resultados. Solo dos apartados dependen de tu versión de React, y van marcados: la integración con React Router 8 y la comparación con useOptimistic. Los ejemplos usan @tanstack/react-query 5.105.1.


1. Por qué hace falta

En el módulo 09 pediste datos con useEffect y en el módulo 12 lo encerraste en un useFetch. Es una buena forma de aprender, pero veamos qué hace falta solo para pedir una lista bien, cuidando la condición de carrera:

import { useEffect, useState } from 'react'
import { peticion, type Empleado, type Pagina } from './api'

export function useEmpleadosManual() {
  const [datos, setDatos] = useState<Pagina<Empleado> | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [cargando, setCargando] = useState(true)

  useEffect(() => {
    const control = new AbortController()

    peticion<Pagina<Empleado>>('/empleados?tamano=8', { signal: control.signal })
      .then(setDatos)
      .catch((e: Error) => {
        if (e.name !== 'AbortError') setError(e)
      })
      .finally(() => {
        if (!control.signal.aborted) setCargando(false)
      })

    return () => control.abort()
  }, [])

  return { datos, error, cargando }
}

Son más de veinte líneas para una sola lista, y aún no hace casi nada de lo que una aplicación real necesita:

Problema Qué pasa con el código anterior
Sin caché Sales de la pantalla y vuelves: otro spinner, otra petición, aunque los datos sean de hace dos segundos
Peticiones repetidas Si tres componentes usan el hook, salen tres peticiones iguales
Datos anticuados Otro usuario cambia un empleado y tú sigues viendo el valor viejo hasta recargar la página
Sin reintentos Un fallo puntual de red deja la pantalla en error
Sin coordinación Das de alta un empleado: ¿quién avisa a la lista de que debe recargarse?
Paginación incómoda Al cambiar de página, la tabla se vacía y salta el scroll
Actualización optimista a mano Copiar el estado, escribir, deshacer si falla, evitar que una respuesta vieja lo pise…

Puedes programar todo esto, pero entonces estarías escribiendo una biblioteca de caché. La causa de fondo es que estamos usando una herramienta pensada para un tipo de estado con otro que se comporta de forma muy diferente.

Estado de cliente y estado de servidor

Comparación entre el estado de cliente, propio del navegador, y el estado de servidor, del que solo tienes una copia

Figura 1. El estado de cliente es tuyo. Del estado de servidor solo tienes una copia, que puede estar anticuada.

📘 Concepto: El estado de cliente pertenece a la aplicación: qué pestaña está abierta, qué texto hay en un campo. Solo tú lo cambias, así que siempre es correcto. El estado de servidor vive en otra máquina: lo que guardas en el navegador es una copia que caduca sin avisar, porque otros pueden cambiar el original.

Esta distinción aclara muchas decisiones. Si metes los empleados en un useState o en un almacén global, los tratas como si fueran tuyos, y a partir de ahí tienes que inventar cómo refrescarlos, cuándo caducan y qué pasa si dos pantallas los piden. TanStack Query parte de la idea contraria: los datos del servidor son una caché, y cada dato tiene una antigüedad.

Qué es TanStack Query y qué no es

TanStack Query, conocido durante años como React Query, es una biblioteca que administra esa caché: decide cuándo un dato sirve, cuándo pedirlo otra vez y quién debe enterarse cuando cambia.

Lo que no es:

  • No es un cliente HTTP. No sabe nada de fetch, URLs ni JSON. Tú le das una función que devuelve una promesa, y Query se ocupa del resto. Podría leer de una base de datos local con la misma API.
  • No sustituye a useState. El estado de cliente sigue donde estaba. El módulo 20 trata de lo que queda cuando sacas los datos del servidor, que suele ser mucho menos de lo que parecía.
  • No es exclusiva de React. El núcleo, @tanstack/query-core, no depende de ningún framework, y @tanstack/react-query es su adaptador en forma de hooks.

2. Prepara el proyecto

Crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell, e instala Query:

npm create vite@latest modulo-19 -- --template react-ts
cd modulo-19
npm install
npm install @tanstack/react-query
npm run dev

Descarga la API de ejemplo y arráncala en otra terminal con un retardo de 300 ms por respuesta, para que los estados de carga se vean:

RETARDO=300 node server.mjs

En PowerShell son dos órdenes: $env:RETARDO=300; node server.mjs. La API queda en http://localhost:3000/api. Sus datos están en memoria, así que al reiniciarla vuelven al estado inicial: úsalo cuando hayas borrado o cambiado demasiado.

El cliente y el proveedor

Query necesita un objeto QueryClient, que contiene la caché, y un componente que lo ponga a disposición de toda la aplicación. Sustituye src/main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './index.css'
import App from './App.tsx'

// Se crea fuera de cualquier componente: un render no debe crear otra caché.
const clienteConsultas = new QueryClient()

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={clienteConsultas}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
)

Fíjate en dónde se crea el cliente. Si escribes new QueryClient() dentro de un componente, cada render crea una caché nueva y vacía, y todas las consultas vuelven a empezar. Debe ser único y estable: fuera de los componentes, o en un useState con función inicial, como hará la sección 15.

Un cliente pequeño para la API

Todos los ejemplos usan este fichero, src/api.ts. Lo más importante de él para este módulo es que lanza un error cuando la respuesta no es correcta:

const BASE = 'http://localhost:3000/api'

export interface Pagina<T> {
  contenido: T[]
  pagina: number
  tamano: number
  total: number
  paginas: number
}

export interface Departamento {
  deptId: number
  nombre: string
  localidad: string
  presupuesto: number | null
}

export interface Empleado {
  empId: number
  nombre: string
  apellidos: string
  email: string
  oficio: string
  salario: number
  deptId: number | null
  departamento: string | null
}

export type NuevoEmpleado = Pick<
  Empleado,
  'nombre' | 'apellidos' | 'email' | 'oficio' | 'salario' | 'deptId'
>

export type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO'

export interface Pedido {
  pedidoId: number
  clienteId: number
  fechaPedido: string
  estado: EstadoPedido
}

// Un error de la API conserva el código HTTP y los errores de cada campo.
export class ApiError extends Error {
  status: number
  detalles?: Record<string, string>

  constructor(status: number, mensaje: string, detalles?: Record<string, string>) {
    super(mensaje)
    this.name = 'ApiError'
    this.status = status
    this.detalles = detalles
  }
}

// Solo para este módulo: entra siempre como la usuaria ADMIN de la API.
// El inicio de sesión de verdad se construye en el proyecto final.
let token: Promise<string> | null = null

function obtenerToken(): Promise<string> {
  token ??= fetch(`${BASE}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
  })
    .then((r) => r.json())
    .then((sesion: { token: string }) => sesion.token)
  return token
}

export async function peticion<T>(ruta: string, opciones: RequestInit = {}): Promise<T> {
  const cabeceras = new Headers(opciones.headers)
  const metodo = opciones.method ?? 'GET'

  if (metodo !== 'GET') {
    cabeceras.set('Authorization', `Bearer ${await obtenerToken()}`)
    cabeceras.set('Content-Type', 'application/json')
  }

  const respuesta = await fetch(`${BASE}${ruta}`, { ...opciones, headers: cabeceras })

  if (!respuesta.ok) {
    const cuerpo = await respuesta.json().catch(() => null)
    throw new ApiError(
      respuesta.status,
      cuerpo?.mensaje ?? `Error ${respuesta.status}`,
      cuerpo?.detalles,
    )
  }

  // 204 No Content no trae cuerpo que leer
  return respuesta.status === 204 ? (undefined as T) : respuesta.json()
}

⚠️ Importante: fetch solo rechaza su promesa cuando falla la red. Un 404 o un 500 son respuestas válidas para fetch, y su promesa se cumple. Query decide si algo ha ido mal según si tu función lanza un error, así que si no compruebas respuesta.ok, un 500 se guardará en caché como si fueran datos buenos. Si tu proyecto usa una biblioteca como Axios, esta comprobación ya la hace ella.

Para ver los ejemplos, borra src/App.css y sustituye src/index.css por estas reglas. Cada ejemplo completo sustituye a src/App.tsx.

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 760px;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.5rem;
  font-size: 1.15rem;
  color: #1a237e;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 0.75rem;
}

button {
  font: inherit;
  padding: 0.3rem 0.75rem;
  border: 1px solid #3f51b5;
  border-radius: 6px;
  background: #fff;
  color: #283593;
  cursor: pointer;
}

button:disabled {
  opacity: 0.5;
  cursor: default;
}

input,
select {
  font: inherit;
  padding: 0.3rem 0.5rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  text-align: left;
  padding: 0.3rem 0.5rem;
  border-bottom: 1px solid #e8eaf6;
}

th {
  color: #1a237e;
}

td.numero,
th.numero {
  text-align: right;
}

.atenuado {
  opacity: 0.55;
}

.discreto {
  color: #607d8b;
  font-size: 0.9rem;
}

.error {
  color: #c62828;
}

.traza {
  font-family: Consolas, monospace;
  font-size: 0.85rem;
  background: #eceff1;
  border-radius: 6px;
  padding: 0.5rem 0.75rem;
  white-space: pre-wrap;
  min-height: 2rem;
  margin: 0.5rem 0 0;
}

3. Tu primera consulta

import { useQuery } from '@tanstack/react-query'
import { peticion, type Empleado, type Pagina } from './api'

export default function App() {
  const consulta = useQuery({
    queryKey: ['empleados'],
    queryFn: ({ signal }) =>
      peticion<Pagina<Empleado>>('/empleados?tamano=8&orden=apellidos', { signal }),
  })

  if (consulta.isPending) return <p>Cargando empleados...</p>
  if (consulta.isError) return <p className="error">{consulta.error.message}</p>

  // Aquí TypeScript ya sabe que data no es undefined
  const { contenido, total } = consulta.data

  return (
    <section className="tarjeta">
      <h2>Empleados ({total})</h2>
      <table>
        <thead>
          <tr>
            <th>Nombre</th>
            <th>Oficio</th>
            <th>Departamento</th>
          </tr>
        </thead>
        <tbody>
          {contenido.map((e) => (
            <tr key={e.empId}>
              <td>
                {e.apellidos}, {e.nombre}
              </td>
              <td>{e.oficio}</td>
              <td>{e.departamento ?? 'Sin departamento'}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  )
}

Tabla de ocho empleados con su oficio y departamento

Figura 2. La lista de empleados obtenida con useQuery. Sin un solo useState ni useEffect.

useQuery recibe dos cosas imprescindibles:

Opción Qué es Para qué sirve
queryKey Un array serializable Es el nombre del dato en la caché. Dos consultas con la misma clave comparten datos
queryFn Una función que devuelve una promesa Sabe cómo obtener el dato. Query decide cuándo llamarla

Prueba esto antes de seguir: abre la pestaña Network del navegador, cambia a otra pestaña y vuelve. Sale una petición nueva a /empleados sin que hayas escrito nada para ello. La tabla no parpadea, porque se ve lo que había mientras llega la respuesta. Lo explica la sección 5.

Tres detalles del código

Comprobar antes de usar. isPending e isError son lo que TypeScript usa para acotar el tipo. Tras los dos if, consulta.data deja de ser undefined. Esto solo funciona si no desestructuras con const { data } = ... por separado de isPending: guarda el objeto entero, o desestructura en la misma línea y comprueba esas variables.

La función recibe un contexto. Su propiedad signal es un AbortSignal que Query activa cuando la petición deja de interesar: por ejemplo, si el componente se desmonta y nadie más usa ese dato. Pasarlo a fetch cancela la petición de verdad, sin que tengas que crear un AbortController como en el módulo 09.

La función no debe devolver undefined. Si lo hace, Query lo trata como error. Esto es lo que se obtiene:

["u"] data is undefined

Si un endpoint no devuelve nada, haz que tu función devuelva null.

Dos peticiones en desarrollo

Si miras la pestaña Network con <StrictMode> activo verás dos peticiones iguales al cargar. No es un fallo de Query. Strict Mode monta el componente, lo desmonta y lo vuelve a montar, como explica el módulo 05. Al desmontarse, Query se queda sin nadie interesado en la petición y, como tu función ha leído signal, la cancela. Al volver a montarse lanza otra.

En la compilación de producción de este ejemplo se ha medido una sola petición. Si tu función no usa signal, Query no puede cancelarla y aprovecha la que ya está en marcha, pero no por eso debes quitarlo: cancelar es lo que evita trabajo inútil cuando el usuario cambia de filtro.


4. status y fetchStatus

Un isLoading único no basta para describir una consulta, porque hay dos preguntas distintas: qué datos tengo y qué está haciendo la petición. Pueden darse casi todas las combinaciones. Puedes tener datos y estar recargando, o no tener datos y estar parado porque no hay conexión.

Matriz de tres por tres que cruza los valores de status con los de fetchStatus y describe cada situación

Figura 3. Las dos dimensiones de una consulta. Cada casilla es una situación real.

Propiedad Equivale a Úsala para
isPending status === 'pending' Saber que aún no hay datos
isSuccess status === 'success' Saber que hay datos
isError status === 'error' Saber que la última petición falló
isFetching fetchStatus === 'fetching' Mostrar un indicador discreto de actividad
isPaused fetchStatus === 'paused' Avisar de que no hay conexión
isLoading isPending && isFetching Mostrar el esqueleto de la primera carga
isRefetching isFetching && !isPending Saber que es una recarga, no la primera carga

isPending o isLoading

Parecen lo mismo, y casi siempre lo son. La diferencia aparece con una consulta desactivada, que no tiene datos pero tampoco está pidiendo nada. Se ha medido con enabled: false:

status: pending   fetchStatus: idle   isPending: true   isLoading: false

Si pintas “Cargando…” con isPending en una consulta que espera a que el usuario elija algo, se quedará cargando para siempre. La regla práctica: usa isPending para acotar el tipo cuando la consulta siempre se ejecuta, e isLoading para el indicador cuando pueda estar desactivada.

Los errores no borran los datos

Si tienes datos y una recarga falla, status pasa a error pero data conserva el valor anterior. El orden de los if de la sección 3 sustituye la tabla por el mensaje de error, y a veces eso es peor para el usuario que seguir viendo datos de hace un minuto. Para esos casos:

if (consulta.isPending) return <p>Cargando...</p>
if (consulta.isLoadingError) return <p className="error">{consulta.error.message}</p>

// Aquí hay datos, aunque la última recarga haya fallado
return (
  <>
    {consulta.isRefetchError && <p className="error">Datos sin actualizar</p>}
    <Tabla filas={consulta.data.contenido} />
  </>
)

isLoadingError significa que falló la primera carga, e isRefetchError que falló una recarga con datos ya en pantalla.

Sin conexión

Por defecto, si el navegador no tiene red, Query no intenta la petición: la deja en pausa y la lanza al recuperar la conexión. Con el laboratorio de la sección siguiente, cortando la red en el navegador, se ha obtenido:

sin red:  status success   fetchStatus paused
con red:  status success   fetchStatus idle

Esto se controla con la opción networkMode, que vale 'online' por defecto. Con 'always' Query lanza la petición aunque no haya red, algo útil si tu queryFn no depende de ella.


5. La caché por dentro

Aquí está el centro del módulo. Si entiendes esta sección, todo lo demás encaja.

Mostrar lo que hay, y preguntar por detrás

Query sigue una estrategia llamada stale-while-revalidate: si tiene un dato, aunque sea antiguo, lo devuelve al momento, y si está obsoleto pide el nuevo por detrás. Cuando llega, actualiza la pantalla. El usuario nunca espera por algo que ya has visto, y acaba viendo lo último.

Cada entrada de la caché pasa por estas fases:

Ciclo de una entrada de la caché: fresca, obsoleta, inactiva y borrada, con staleTime y gcTime como relojes

Figura 4. Dos relojes independientes. staleTime dice cuándo deja de ser fiable un dato; gcTime, cuándo se tira.

Los dos se confunden continuamente, así que grábate esto:

  • staleTime responde a “¿debo volver a preguntar?”. Vale 0 por defecto.
  • gcTime responde a “¿lo sigo guardando?”. Vale 5 minutos por defecto (300 000 ms, comprobado en la entrada de la caché). Su nombre viene de garbage collection, recogida de basura, y su reloj empieza cuando el último componente que usaba el dato se desmonta.

Con staleTime: 0, todo dato es obsoleto desde el instante en que llega. Por eso viste la recarga al cambiar de pestaña. Es un valor por defecto prudente: prefiere gastar una petición a enseñar algo viejo.

Laboratorio: míralo funcionar

Este componente tiene staleTime de 3 s y gcTime de 6 s, valores muy cortos para poder verlo. Apunta en una traza cada vez que se monta, se desmonta o sale una petición.

import { useRef, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { peticion, type Departamento } from './api'

const CLAVE = ['departamento', 30]

function Detalle({ apuntar }: { apuntar: (texto: string) => void }) {
  const consulta = useQuery({
    queryKey: CLAVE,
    queryFn: async ({ signal }) => {
      apuntar('petición a la API')
      return peticion<Departamento>('/departamentos/30', { signal })
    },
    staleTime: 3000, // 3 s como dato fresco
    gcTime: 6000, // 6 s en caché sin nadie que lo use
  })

  return (
    <div>
      <p>
        status: <strong id="status">{consulta.status}</strong> | fetchStatus:{' '}
        <strong id="fetch">{consulta.fetchStatus}</strong> | ¿obsoleto?:{' '}
        <strong>{consulta.isStale ? 'sí' : 'no'}</strong>
      </p>
      <p>{consulta.data ? `${consulta.data.nombre} (${consulta.data.localidad})` : 'Sin datos'}</p>
    </div>
  )
}

export default function App() {
  const cliente = useQueryClient()
  const [visible, setVisible] = useState(false)
  const [traza, setTraza] = useState<string[]>([])
  const inicio = useRef(0)

  function apuntar(texto: string) {
    inicio.current ||= performance.now()
    const ms = Math.round(performance.now() - inicio.current)
    setTraza((t) => [...t, `${String(ms).padStart(6)} ms  ${texto}`])
  }

  function alternar() {
    apuntar(visible ? 'desmonto' : 'monto')
    setVisible(!visible)
  }

  return (
    <section className="tarjeta">
      <h2>Laboratorio de caché</h2>
      <div className="fila">
        <button id="alternar" onClick={alternar}>
          {visible ? 'Desmontar' : 'Montar'}
        </button>
        <button id="invalidar" onClick={() => cliente.invalidateQueries({ queryKey: CLAVE })}>
          Invalidar
        </button>
        <button id="eliminar" onClick={() => cliente.removeQueries({ queryKey: CLAVE })}>
          Eliminar de la caché
        </button>
      </div>
      {visible && <Detalle apuntar={apuntar} />}
      <pre className="traza">{traza.join('\n')}</pre>
    </section>
  )
}

Esta es la traza real que se obtiene al montar y desmontar en estos tiempos, con RETARDO a 200 ms:

     0 ms  monto
    22 ms  petición a la API
    31 ms  petición a la API        ← Strict Mode: la primera se canceló
  1128 ms  desmonto
  2175 ms  monto                    ← dato fresco (2 s): sin petición
  2729 ms  desmonto
  4298 ms  monto                    ← dato obsoleto (4 s): se ve y se recarga
  4302 ms  petición a la API
  4305 ms  petición a la API
  4899 ms  desmonto
 11996 ms  monto                    ← 7 s sin nadie: la caché se borró
 12001 ms  petición a la API
 12003 ms  petición a la API
 12678 ms  petición a la API        ← botón Invalidar

Lo que dice la pantalla justo después de cada montaje confirma la teoría:

Momento status fetchStatus Qué ve el usuario
Monto a los 2 s success idle Los datos, al instante y sin petición
Monto a los 4 s success fetching Los datos antiguos, al instante, y se actualizan solos
Monto a los 12 s pending fetching Sin datos: carga completa, como la primera vez

El laboratorio de caché con los botones, el estado actual y la traza de eventos

Figura 5. El laboratorio tras la secuencia anterior. Las líneas dobles son efecto de Strict Mode y desaparecen en producción.

✏️ Ejercicio: Cambia gcTime a Infinity y repite la secuencia, esperando más de 7 s: el dato sigue ahí. Cambia luego staleTime a Infinity y comprueba que no vuelve a salir ninguna petición, ni siquiera al cambiar de pestaña. Pulsa entonces Invalidar: sí sale, porque invalidar marca el dato como obsoleto sea cual sea su staleTime.

¿Qué valor de staleTime pongo?

Es la decisión de configuración más importante, y se toma pensando en el negocio: ¿cuánto daño hace mostrar este dato un poco anticuado?

Dato staleTime razonable Por qué
Stock de un producto al comprar 0 Un dato viejo tiene consecuencias
Lista de pedidos 30_000 Medio minuto de retraso es aceptable y ahorra muchas peticiones
Departamentos, categorías 5 * 60_000 Cambian muy poco
Países, configuración Infinity Solo cambian con una invalidación tuya
Datos que nunca cambian 'static' Ni siquiera una invalidación los recarga

Infinity y 'static' se parecen, con una diferencia que se ha medido: tras invalidateQueries, una consulta con staleTime: 'static' no sale a la red y sigue sin estar obsoleta. Usa 'static' solo para lo que de verdad no puede cambiar.

staleTime también acepta una función que recibe la consulta, por si la caducidad depende de los datos.

Cuándo se recarga sola

Una consulta obsoleta se recarga en cuatro momentos:

Disparador Opción Por defecto
Se monta un componente que la usa refetchOnMount true
La ventana recupera el foco refetchOnWindowFocus true
Vuelve la conexión refetchOnReconnect true
Cada cierto tiempo refetchInterval false

Las tres primeras admiten también false (nunca) y 'always' (aunque el dato esté fresco). Muchos alumnos desactivan refetchOnWindowFocus porque “salen demasiadas peticiones”. Casi siempre la solución buena es subir staleTime, que respeta las recargas útiles y elimina las inútiles.

refetchInterval recarga aunque el dato esté fresco, y por defecto se detiene con la pestaña en segundo plano; refetchIntervalInBackground: true lo mantiene activo.

Reintentos

Si la función de consulta falla, Query la reintenta 3 veces antes de declarar el error, con esperas crecientes. Se han contado 4 llamadas en una función que siempre falla. Mientras tanto, failureCount y failureReason te dicen qué pasa, y la consulta sigue en pending.

Esto tiene una consecuencia que desconcierta la primera vez: una petición a un recurso que no existe tarda varios segundos en mostrar el error, porque Query insiste aunque un 404 no se vaya a arreglar. La sección 15 lo corrige de forma global.

Valor de retry Efecto
false o 0 Sin reintentos
3 Tres reintentos
true Reintentos infinitos
(fallos, error) => boolean Decides según el error

Deduplicación

Dos componentes montan a la vez con la misma clave: sale una petición, medida con dos useQuery en un mismo componente. Por eso puedes llamar al mismo hook en todos los sitios que necesiten el dato, sin pasar props desde arriba ni preocuparte por el coste.

Compartición estructural

Al recibir una respuesta, Query compara el JSON nuevo con el anterior y conserva las referencias de lo que no ha cambiado. Si la respuesta es idéntica, data es el mismo objeto. Comprobado con un refetch que devuelve los mismos valores:

misma referencia tras refetch igual: true

Esto importa para el módulo 14: un useMemo o un memo que dependan de data no se invalidan por una recarga sin cambios. Se puede desactivar con structuralSharing: false, y solo funciona con datos serializables como JSON.

Solo te avisa de lo que lees

Un useQuery devuelve muchas propiedades, y podría parecer que cualquier cambio en cualquiera provoca un render. No es así: Query registra qué propiedades lee tu componente y solo avisa si cambian esas. Se han medido dos componentes con la misma consulta durante una recarga que no cambia datos:

Componente que lee solo data:          0 renders extra
Componente que lee data e isFetching:  2 renders extra

Es otra razón para desestructurar solo lo que usas. Escribir const consulta = useQuery(...) no rompe esto, porque el seguimiento se hace al leer cada propiedad, pero si haces {...consulta} para pasarlo a un hijo estás leyéndolas todas.


6. Claves de consulta

La clave es la identidad del dato. Si la diseñas bien, invalidar es trivial. Si la diseñas mal, tendrás datos cruzados entre pantallas.

La regla de oro

⚠️ Importante: Todo lo que use la función de consulta y pueda cambiar debe estar en la clave. La clave funciona como el array de dependencias de un efecto: cuando cambia, Query busca otra entrada de la caché y, si no existe, llama a la función.

// MAL: las dos páginas comparten entrada, y al cambiar de página no pasa nada
useQuery({
  queryKey: ['empleados'],
  queryFn: () => peticion(`/empleados?pagina=${pagina}`),
})

// BIEN: cada página es una entrada distinta
useQuery({
  queryKey: ['empleados', { pagina }],
  queryFn: () => peticion(`/empleados?pagina=${pagina}`),
})

Cómo se comparan

Query convierte la clave en un texto para buscarla, ordenando antes las propiedades de los objetos. Comprobado:

Consulta guardada Se busca con ¿Encuentra?
['a', { x: 1, y: 2 }] ['a', { y: 2, x: 1 }] Sí: el orden de las propiedades no importa
['a', { x: 1, y: 2 }] ['a', { y: 2, x: 1, z: undefined }] Sí: undefined se ignora
['b', 1, 2] ['b', 2, 1] No: el orden del array sí importa

Esto permite pasar un objeto de filtros directamente, y que un filtro vacío (undefined) dé la misma entrada que su ausencia.

Invalidar por prefijo

queryClient.invalidateQueries marca consultas como obsoletas y recarga las que estén activas. Busca por prefijo, y por eso conviene ir de lo general a lo particular:

Árbol de claves de empleados donde lista y sus dos páginas quedan marcadas al invalidar por prefijo

Figura 6. Las claves forman un árbol. Invalidar una rama alcanza a todo lo que cuelga de ella.

Con cinco consultas montadas y contando llamadas, este es el resultado medido:

Llamada Qué se recarga
invalidateQueries({ queryKey: ['e', 'lista'] }) ['e', 'lista'] y ['e', 'lista', { p: 1 }]
... exact: true Solo ['e', 'lista']
... refetchType: 'none' Nada: las marca como obsoletas y se recargarán cuando toque
invalidateQueries({ predicate: (q) => q.queryKey[1] === 'detalle' }) Las que cumplan la función

Las consultas inactivas también coinciden, pero no se recargan: quedan marcadas y se pedirán cuando alguien vuelva a usarlas. Comprobado: sin componentes, isInvalidated queda a true y no sale ninguna petición. Es justo lo que quieres, porque no tiene sentido pedir datos que nadie mira.

Un fichero con todas las consultas

Escribir claves a mano por toda la aplicación invita a las erratas: ['empleado'] en un sitio y ['empleados'] en otro son dos cachés. La solución es reunirlas, junto con su función, usando queryOptions. Crea src/consultas.ts:

import { queryOptions } from '@tanstack/react-query'
import { peticion, type Departamento, type Empleado, type Pagina } from './api'

export interface FiltroEmpleados {
  pagina?: number
  tamano?: number
  q?: string
}

export function comoParametros(filtro: FiltroEmpleados) {
  const parametros = new URLSearchParams({ orden: 'empId' })
  if (filtro.pagina) parametros.set('pagina', String(filtro.pagina))
  if (filtro.tamano) parametros.set('tamano', String(filtro.tamano))
  if (filtro.q) parametros.set('q', filtro.q)
  return parametros
}

// Todas las claves salen de aquí: nadie escribe un array a mano.
export const claves = {
  empleados: () => ['empleados'] as const,
  listas: () => [...claves.empleados(), 'lista'] as const,
  lista: (filtro: FiltroEmpleados) => [...claves.listas(), filtro] as const,
  detalle: (id: number) => [...claves.empleados(), 'detalle', id] as const,
  departamentos: () => ['departamentos'] as const,
}

export const listaEmpleados = (filtro: FiltroEmpleados) =>
  queryOptions({
    queryKey: claves.lista(filtro),
    queryFn: ({ signal }) =>
      peticion<Pagina<Empleado>>(`/empleados?${comoParametros(filtro)}`, { signal }),
  })

export const detalleEmpleado = (id: number) =>
  queryOptions({
    queryKey: claves.detalle(id),
    queryFn: ({ signal }) => peticion<Empleado>(`/empleados/${id}`, { signal }),
  })

export const departamentos = () =>
  queryOptions({
    queryKey: claves.departamentos(),
    queryFn: ({ signal }) => peticion<Pagina<Departamento>>('/departamentos', { signal }),
    select: (pagina) => pagina.contenido,
    staleTime: 5 * 60_000,
  })

queryOptions no hace nada en ejecución: devuelve el mismo objeto que recibe. Su valor está en los tipos, porque “marca” la clave con el tipo del dato. Por eso cliente.getQueryData(listaEmpleados(filtro).queryKey) devuelve un dato tipado sin indicar nada más, como verás en el ejercicio 5. Además, el mismo objeto sirve para useQuery, useSuspenseQuery y queryClient.query, y admite que le añadas opciones con spread:

const consulta = useQuery({ ...listaEmpleados(filtro), staleTime: 60_000 })

Este módulo llama a sus factorías así, con nombres de dato. Otra convención muy extendida es agruparlas en un objeto (empleadosConsultas.lista(filtro)). Lo importante es que haya un único sitio.


7. Consultas dependientes, paralelas y select

Esperar a tener lo necesario

Muchas consultas dependen de una elección del usuario o del resultado de otra. Hay dos formas de decir “todavía no”:

  • enabled: false, una opción booleana.
  • queryFn: skipToken, un valor especial que Query reconoce. Es lo recomendable con TypeScript, porque al estar dentro de la condición el tipo de la variable queda acotado sin usar !.
import { useState } from 'react'
import { skipToken, useQuery } from '@tanstack/react-query'
import { peticion, type Departamento, type Empleado, type Pagina } from './api'

function SelectorDepartamento(props: {
  valor: number | null
  alCambiar: (id: number | null) => void
}) {
  const { data: opciones = [], isPending } = useQuery({
    queryKey: ['departamentos'],
    queryFn: ({ signal }) => peticion<Pagina<Departamento>>('/departamentos', { signal }),
    select: (pagina) => pagina.contenido.toSorted((a, b) => a.nombre.localeCompare(b.nombre)),
    staleTime: 5 * 60_000, // cambian poco: cinco minutos frescos
  })

  return (
    <select
      aria-label="Departamento"
      disabled={isPending}
      value={props.valor ?? ''}
      onChange={(e) => props.alCambiar(e.target.value ? Number(e.target.value) : null)}
    >
      <option value="">Elige un departamento</option>
      {opciones.map((d) => (
        <option key={d.deptId} value={d.deptId}>
          {d.nombre}
        </option>
      ))}
    </select>
  )
}

export default function App() {
  const [deptId, setDeptId] = useState<number | null>(null)
  const [texto, setTexto] = useState('')

  // La clave incluye TODO lo que usa la función de consulta
  const empleados = useQuery({
    queryKey: ['empleados', 'por-departamento', { deptId, q: texto }],
    queryFn:
      deptId === null
        ? skipToken // sin departamento no hay nada que pedir
        : ({ signal }) => {
            const parametros = new URLSearchParams({ deptId: String(deptId), q: texto })
            return peticion<Pagina<Empleado>>(`/empleados?${parametros}`, { signal })
          },
    select: (pagina) => pagina.contenido,
  })

  return (
    <section className="tarjeta">
      <h2>Empleados por departamento</h2>
      <div className="fila">
        <SelectorDepartamento valor={deptId} alCambiar={setDeptId} />
        <input
          aria-label="Buscar"
          placeholder="Buscar..."
          value={texto}
          onChange={(e) => setTexto(e.target.value)}
        />
      </div>

      {deptId === null && <p className="discreto">Elige un departamento para ver su gente.</p>}
      {empleados.isLoading && <p>Cargando...</p>}
      {empleados.isError && <p className="error">{empleados.error.message}</p>}
      {empleados.data?.length === 0 && <p>Nadie coincide con la búsqueda.</p>}
      <ul>
        {empleados.data?.map((e) => (
          <li key={e.empId}>
            {e.nombre} {e.apellidos} ({e.oficio})
          </li>
        ))}
      </ul>
    </section>
  )
}

Selector de departamento con INFORMÁTICA elegido, buscador con el texto ma y cinco empleados resultantes

Figura 7. Cada combinación de departamento y texto es una entrada propia de la caché.

Aquí hay varias ideas juntas:

  • La clave lleva los dos filtros, y al cambiarlos Query lanza otra consulta. Al escribir deprisa, la petición anterior se cancela: pulsando dos letras seguidas se ha medido una petición abortada con net::ERR_ABORTED, gracias a signal.
  • isLoading, no isPending. Sin departamento la consulta está en pending, pero no hace nada.
  • data = [] al desestructurar evita comprobar undefined en el selector.

Hay una diferencia entre las dos formas de desactivar. Con enabled: false, refetch() funciona y lanza la función, comprobado. Con skipToken, no hay función que lanzar:

refetch con enabled: false  → lanza la petición
refetch con skipToken       → error: Missing queryFn: '["t"]'

Si necesitas un botón “Cargar” manual, usa enabled: false y refetch, aunque casi siempre queda mejor guardar la elección en estado y dejar que la clave cambie.

Transformar con select

select recibe lo que hay en la caché y devuelve lo que quiere ver este componente. La caché no cambia, así que dos componentes pueden ver lo mismo de formas distintas sin duplicar datos ni peticiones.

Un detalle de rendimiento medido: una función en línea es distinta en cada render, así que se vuelve a ejecutar. Una función estable solo se ejecuta cuando cambian los datos:

Tras dos renders más:  select en línea +2 ejecuciones   select estable +0

Con transformaciones sencillas no importa. Si select ordena miles de filas, sácala fuera del componente, o memorízala con useCallback.

Varias consultas a la vez

Dos useQuery seguidos ya van en paralelo, porque ninguno espera al otro. El problema aparece cuando no sabes cuántas consultas hay y no puedes llamar a un hook dentro de un bucle. Para eso está useQueries:

import { useQueries, useQuery } from '@tanstack/react-query'
import { peticion, type Departamento, type Empleado, type Pagina } from './api'

function Plantillas({ departamentos }: { departamentos: Departamento[] }) {
  const resumen = useQueries({
    queries: departamentos.map((d) => ({
      queryKey: ['departamentos', d.deptId, 'empleados'],
      queryFn: ({ signal }: { signal: AbortSignal }) =>
        peticion<Pagina<Empleado>>(`/empleados?deptId=${d.deptId}&tamano=100`, { signal }),
    })),
    // combine reúne todos los resultados en un único valor
    combine: (resultados) => ({
      cargando: resultados.some((r) => r.isPending),
      filas: resultados.map((r, i) => ({
        ...departamentos[i],
        personas: r.data?.total ?? null,
        masaSalarial: r.data?.contenido.reduce((suma, e) => suma + e.salario, 0) ?? null,
      })),
    }),
  })

  return (
    <table className={resumen.cargando ? 'atenuado' : ''}>
      <thead>
        <tr>
          <th>Departamento</th>
          <th className="numero">Personas</th>
          <th className="numero">Masa salarial</th>
        </tr>
      </thead>
      <tbody>
        {resumen.filas.map((f) => (
          <tr key={f.deptId}>
            <td>{f.nombre}</td>
            <td className="numero">{f.personas ?? '...'}</td>
            <td className="numero">{f.masaSalarial?.toLocaleString('es-ES') ?? '...'} €</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

export default function App() {
  const departamentos = useQuery({
    queryKey: ['departamentos'],
    queryFn: ({ signal }) => peticion<Pagina<Departamento>>('/departamentos', { signal }),
    select: (pagina) => pagina.contenido,
  })

  return (
    <section className="tarjeta">
      <h2>Plantilla por departamento</h2>
      {departamentos.data ? <Plantillas departamentos={departamentos.data} /> : <p>Cargando...</p>}
    </section>
  )
}

Tabla con los siete departamentos, sus personas y su masa salarial

Figura 8. Una consulta por departamento, reunidas con combine.

combine transforma el array de resultados en un solo valor. Sin ella, useQueries devuelve un array nuevo en cada render.

Este ejemplo enseña la herramienta, pero en una aplicación real siete peticiones para una tabla son un mal diseño de API, y conviene un endpoint que dé el total. De hecho, GET /api/estadisticas lo hace: lo usarás en el ejercicio 2.


8. Mutaciones

Las consultas leen; las mutaciones cambian algo en el servidor. Se diferencian en un punto clave: una consulta se ejecuta sola cuando hace falta, mientras que una mutación solo se ejecuta cuando se lo pides.

Un alta y una baja

import { useState, type SubmitEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, peticion, type Empleado, type NuevoEmpleado } from './api'
import { claves, listaEmpleados } from './consultas'

const FILTRO = { tamano: 6 }

function useCrearEmpleado() {
  const cliente = useQueryClient()

  return useMutation({
    mutationFn: (nuevo: NuevoEmpleado) =>
      peticion<Empleado>('/empleados', { method: 'POST', body: JSON.stringify(nuevo) }),
    // Al devolver la promesa, la mutación sigue "pendiente" hasta que acaba la recarga
    onSuccess: () => cliente.invalidateQueries({ queryKey: claves.listas() }),
  })
}

function useBorrarEmpleado() {
  const cliente = useQueryClient()

  return useMutation({
    mutationFn: (id: number) => peticion<void>(`/empleados/${id}`, { method: 'DELETE' }),
    onSuccess: (_datos, id) => {
      cliente.removeQueries({ queryKey: claves.detalle(id) })
      return cliente.invalidateQueries({ queryKey: claves.listas() })
    },
  })
}

function FormularioAlta() {
  const crear = useCrearEmpleado()
  const erroresCampo = crear.error instanceof ApiError ? crear.error.detalles : undefined

  function enviar(evento: SubmitEvent<HTMLFormElement>) {
    evento.preventDefault()
    const formulario = evento.currentTarget
    const datos = new FormData(formulario)

    crear.mutate(
      {
        nombre: String(datos.get('nombre')),
        apellidos: String(datos.get('apellidos')),
        email: String(datos.get('email')),
        oficio: 'PROGRAMADOR',
        salario: 1800,
        deptId: 30,
      },
      { onSuccess: () => formulario.reset() },
    )
  }

  return (
    <form onSubmit={enviar}>
      <div className="fila">
        <input name="nombre" placeholder="Nombre" aria-label="Nombre" />
        <input name="apellidos" placeholder="Apellidos" aria-label="Apellidos" />
        <input name="email" placeholder="Email" aria-label="Email" />
        <button disabled={crear.isPending}>{crear.isPending ? 'Guardando...' : 'Dar de alta'}</button>
      </div>
      {crear.isError && (
        <div className="error" role="alert">
          <p>{crear.error.message}</p>
          <ul>
            {Object.entries(erroresCampo ?? {}).map(([campo, mensaje]) => (
              <li key={campo}>{mensaje}</li>
            ))}
          </ul>
        </div>
      )}
      {crear.isSuccess && <p>Creado con el número {crear.data.empId}.</p>}
    </form>
  )
}

export default function App() {
  const empleados = useQuery(listaEmpleados(FILTRO))
  const borrar = useBorrarEmpleado()
  const [aviso, setAviso] = useState('')

  function pedirBorrado(e: Empleado) {
    setAviso('')
    borrar.mutate(e.empId, {
      onSuccess: () => setAviso(`Borrado ${e.nombre} ${e.apellidos}`),
      onError: (error) => setAviso(error.message),
    })
  }

  return (
    <section className="tarjeta">
      <h2>Alta y baja de empleados</h2>
      <FormularioAlta />
      <p className="discreto">
        {empleados.isFetching ? 'Actualizando...' : `${empleados.data?.total ?? 0} empleados`}
      </p>
      <table>
        <tbody>
          {empleados.data?.contenido.map((e) => (
            <tr key={e.empId}>
              <td>{e.empId}</td>
              <td>
                {e.nombre} {e.apellidos}
              </td>
              <td className="numero">
                <button
                  disabled={borrar.isPending && borrar.variables === e.empId}
                  onClick={() => pedirBorrado(e)}
                >
                  Borrar
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {aviso && <p role="status">{aviso}</p>}
    </section>
  )
}

Formulario de alta con los errores de cada campo devueltos por la API, sobre la lista de empleados

Figura 9. Al enviar el formulario vacío, la API responde 422 y crear.error.detalles trae el mensaje de cada campo.

Secuencia medida al dar de alta un empleado correcto:

POST /empleados
GET  /empleados?orden=empId&tamano=6

La lista se actualiza sola, de 24 a 25, sin que el formulario sepa nada de ella. El borrado de Ana (número 100) responde 409 y muestra “No se puede borrar a un empleado que tiene subordinados”.

💡 Consejo: SubmitEvent es el tipo recomendado en @types/react 19.3, que marca FormEvent como obsoleto, como se contó en el módulo 11. Si tu @types/react es anterior y no lo tiene, usa FormEvent<HTMLFormElement>.

Lo que devuelve useMutation

Propiedad Para qué sirve
mutate(variables, callbacks?) Lanza la mutación. No devuelve nada
mutateAsync(variables) Igual, pero devuelve una promesa que se rechaza si falla
isPending, isSuccess, isError, isIdle Estado de la última llamada
data, error Resultado de la última llamada
variables Lo que se pasó en la última llamada
reset() Vuelve a idle y borra error y data

Las mutaciones no se reintentan por defecto: se ha medido una sola llamada en una que falla. Tiene sentido, porque reintentar un POST puede crear dos pedidos. Si tu operación es idempotente, activa retry.

mutate o mutateAsync

Usa mutate por defecto. Gestiona el error por ti: si falla, queda en error y no se produce ningún rechazo sin capturar. mutateAsync es para cuando necesitas encadenar varias operaciones con await, y entonces tienes que escribir el try/catch:

async function guardarTodo() {
  try {
    const empleado = await crear.mutateAsync(datos)
    await asignarProyecto.mutateAsync({ empId: empleado.empId, proyectoId: 1 })
  } catch (error) {
    console.error(error)
  }
}

Orden de los callbacks

Puedes dar callbacks en dos sitios, y no son equivalentes. Este es el orden medido:

hook.onMutate > mutationFn > hook.onSuccess > hook.onSettled > mutate.onSuccess > mutate.onSettled

La diferencia real entre los dos sitios aparece en dos situaciones, ambas comprobadas:

Situación Callbacks en useMutation Callbacks en mutate
Dos mutate seguidos Se ejecutan los dos Solo el de la última llamada
El componente se desmonta antes de acabar Se ejecutan No se ejecutan

De ahí sale la regla que sigue el ejemplo:

  • En useMutation, lo que debe pasar siempre: tocar la caché, invalidar.
  • En mutate, lo que depende de la pantalla: limpiar el formulario, cerrar un diálogo, mostrar un aviso.

Fíjate en la firma de los callbacks en la versión 5.105.1: onMutate(variables, contexto), onSuccess(datos, variables, resultadoDeOnMutate, contexto), onError(error, variables, resultadoDeOnMutate, contexto) y onSettled(datos, error, variables, resultadoDeOnMutate, contexto). No confundas los dos últimos: el tercer argumento es lo que devolvió tu onMutate, y contexto es un objeto de Query que incluye client, el QueryClient. En MutationCache, como verás en la sección 15, se añade la propia mutación antes de contexto.

Devolver la promesa de la invalidación

El ejemplo escribe onSuccess: () => cliente.invalidateQueries(...), sin llaves. Eso devuelve la promesa, y Query espera a que se resuelva antes de terminar la mutación. Comprobado: durante un onSuccess que tarda, isPending sigue a true.

Así el botón sigue en “Guardando…” hasta que la lista nueva está en pantalla. Con llaves y sin return, el botón se libera antes y el usuario ve un instante la lista antigua.

Invalidar o escribir en la caché

Técnica Cómo Cuándo
Invalidar invalidateQueries Por defecto. Una petición más, pero el dato es seguro
Escribir la respuesta setQueryData(clave, respuesta) La API devuelve el recurso completo: evita una petición
Borrar la entrada removeQueries El recurso ya no existe

Lo normal es combinarlas: escribir el detalle, que conoces exactamente, e invalidar las listas, donde el orden, el total y la paginación pueden cambiar. Lo verás en el ejercicio 5.

⚠️ Importante: Trata los datos de setQueryData como inmutables, igual que el estado de React (módulo 04). Si modificas el objeto que hay en la caché, los suscritos no se enteran, porque Query no ve ningún cambio. setQueryData(clave, undefined) no borra nada: se ha comprobado que conserva el valor.

Mutaciones en orden

Dos mutaciones lanzadas a la vez salen en paralelo. Si dos cambios sobre el mismo recurso deben ir en orden, scope las pone en cola. Medido con dos mutaciones de 10 ms:

useMutation({ scope: { id: 'pedido' }, mutationFn: guardarPedido })
a empieza, a acaba, b empieza, b acaba

9. Paginación

Con la clave ['empleados', 'lista', { pagina }], cada página es una entrada distinta. Funciona, pero tiene un defecto: al pasar a una página que no está en caché, data vale undefined y la tabla desaparece mientras llega. La altura de la página cambia y todo salta.

La solución es placeholderData: keepPreviousData, que mantiene los datos de la clave anterior hasta que llegan los nuevos:

import { useEffect, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { listaEmpleados } from './consultas'

const TAMANO = 8

export default function App() {
  const cliente = useQueryClient()
  const [pagina, setPagina] = useState(1)

  const consulta = useQuery({
    ...listaEmpleados({ pagina, tamano: TAMANO }),
    placeholderData: keepPreviousData, // sigue mostrando la página anterior mientras llega la nueva
  })

  const paginas = consulta.data?.paginas ?? 1
  const hayMas = pagina < paginas

  // Deja preparada la página siguiente antes de que el usuario la pida
  useEffect(() => {
    if (hayMas) {
      cliente
        .query({ ...listaEmpleados({ pagina: pagina + 1, tamano: TAMANO }), staleTime: 30_000 })
        .catch(() => {}) // precargar es opcional: si falla, no molesta a nadie
    }
  }, [cliente, pagina, hayMas])

  return (
    <section className="tarjeta">
      <h2>Empleados paginados</h2>
      {consulta.isError && <p className="error">{consulta.error.message}</p>}

      <table className={consulta.isPlaceholderData ? 'atenuado' : ''}>
        <thead>
          <tr>
            <th>Núm.</th>
            <th>Nombre</th>
            <th>Oficio</th>
          </tr>
        </thead>
        <tbody>
          {consulta.data?.contenido.map((e) => (
            <tr key={e.empId}>
              <td>{e.empId}</td>
              <td>
                {e.nombre} {e.apellidos}
              </td>
              <td>{e.oficio}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <div className="fila" style={{ marginTop: '0.75rem' }}>
        <button disabled={pagina === 1} onClick={() => setPagina((p) => p - 1)}>
          Anterior
        </button>
        <span>
          Página {pagina} de {paginas}
        </span>
        <button
          disabled={!hayMas || consulta.isPlaceholderData}
          onClick={() => setPagina((p) => p + 1)}
        >
          Siguiente
        </button>
        {consulta.isFetching && <span className="discreto">Actualizando...</span>}
      </div>
    </section>
  )
}

Tabla atenuada en la página 2 de 3 mientras llegan los datos, con el botón Siguiente desactivado

Figura 10. Se muestra aún la primera página, atenuada. isPlaceholderData es true, y por eso Siguiente está desactivado.

Claves del código:

  • consulta.isPlaceholderData indica que lo que se ve no es de la clave actual. Se usa para atenuar y para desactivar Siguiente: sin esto, se podría avanzar con un paginas que aún no es el de la página pedida.
  • placeholderData nunca se guarda en la caché. Es un disfraz temporal para la pantalla. Se diferencia de initialData, que sí se guarda como dato real y se considera ya obtenido.
  • Si cambias de página y la nueva ya está en caché, no hay disfraz: se muestra directamente.

Precargar la página siguiente

El efecto deja preparada la página siguiente, así que al pulsar Siguiente el cambio es instantáneo. Medido al cargar:

GET /empleados?orden=empId&pagina=1&tamano=8     (en Strict Mode, dos veces)
GET /empleados?orden=empId&pagina=2&tamano=8

Al pulsar Siguiente, la página 2 aparece sin atenuar y sin salir a la red.

Dos detalles importantes:

  • staleTime: 30_000 en la precarga. Sin él, queryClient.query considera obsoleto cualquier dato y pediría otra vez la página a cada visita.
  • .catch(() => {}). queryClient.query devuelve una promesa que se rechaza si falla. Una precarga es opcional, y su error no debe convertirse en un rechazo sin capturar.

⚠️ Importante: Encontrarás en muchos tutoriales queryClient.prefetchQuery, fetchQuery y ensureQueryData. Siguen funcionando en la versión 5.105.1, pero sus tipos ya los marcan como obsoletos (@deprecated) y anuncian que se eliminarán en la siguiente versión mayor. El método que los sustituye es queryClient.query(opciones), y en consultas infinitas queryClient.infiniteQuery(opciones). Las equivalencias son: prefetchQuery(op) pasa a ser query(op).catch(noop), y ensureQueryData(op) pasa a ser query({ ...op, staleTime: 'static' }).

Su comportamiento es este, comprobado llamando varias veces con el dato ya en caché:

Llamada ¿Sale a la red?
query(op) Sí: sin staleTime, el dato cuenta como obsoleto
query({ ...op, staleTime: 10_000 }) No, si el dato tiene menos de 10 s
query({ ...op, staleTime: 'static' }) No, si hay cualquier dato

Una diferencia con useQuery: queryClient.query no reintenta por defecto. Se ha medido una sola llamada en una función que siempre falla, frente a las 4 de useQuery.

Si precargas desde un componente, existe además el hook usePrefetchQuery(opciones), que evita escribir el efecto.


10. Listas infinitas

Una lista infinita no puede usar useQuery con una clave por página, porque hay que ver todas las páginas juntas. useInfiniteQuery guarda bajo una sola clave un array de páginas:

import { useEffect, useRef } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { peticion, type Empleado, type Pagina } from './api'
import { claves, comoParametros } from './consultas'

const TAMANO = 10

export default function App() {
  const consulta = useInfiniteQuery({
    queryKey: [...claves.empleados(), 'infinita'],
    queryFn: ({ pageParam, signal }) => {
      const filtro = comoParametros({ pagina: pageParam, tamano: TAMANO })
      return peticion<Pagina<Empleado>>(`/empleados?${filtro}`, { signal })
    },
    initialPageParam: 1,
    // undefined significa "no hay más páginas"
    getNextPageParam: (ultima) => (ultima.pagina < ultima.paginas ? ultima.pagina + 1 : undefined),
    select: (datos) => datos.pages.flatMap((pagina) => pagina.contenido),
  })

  const { hasNextPage, isFetchingNextPage, fetchNextPage } = consulta
  const centinela = useRef<HTMLDivElement>(null)

  // Cuando el final de la lista se acerca a la pantalla, pide la página siguiente
  useEffect(() => {
    const nodo = centinela.current
    if (!nodo || !hasNextPage) return

    const observador = new IntersectionObserver(
      ([entrada]) => {
        if (entrada.isIntersecting && !isFetchingNextPage) fetchNextPage()
      },
      { rootMargin: '100px' },
    )
    observador.observe(nodo)
    return () => observador.disconnect()
  }, [hasNextPage, isFetchingNextPage, fetchNextPage])

  if (consulta.isPending) return <p>Cargando...</p>
  if (consulta.isError) return <p className="error">{consulta.error.message}</p>

  return (
    <section className="tarjeta">
      <h2>Empleados ({consulta.data.length} cargados)</h2>
      <ol>
        {consulta.data.map((e) => (
          <li key={e.empId}>
            {e.nombre} {e.apellidos}, {e.oficio}
          </li>
        ))}
      </ol>
      <div ref={centinela} />
      {isFetchingNextPage && <p>Cargando más...</p>}
      {consulta.isFetchNextPageError && (
        <button onClick={() => fetchNextPage()}>Reintentar</button>
      )}
      {!hasNextPage && <p className="discreto">No hay más empleados.</p>}
    </section>
  )
}

Final de la lista infinita con el empleado 24 y el aviso de que no hay más

Figura 11. Al bajar, el IntersectionObserver pide cada página hasta que getNextPageParam devuelve undefined.

Con data sin select, el dato tiene esta forma:

{
  pages: [
    { contenido: [/* 10 */], pagina: 1, paginas: 3, ... },
    { contenido: [/* 10 */], pagina: 2, paginas: 3, ... },
  ],
  pageParams: [1, 2],
}
Opción o propiedad Qué hace
initialPageParam Parámetro de la primera página. Obligatorio
getNextPageParam(ultima, todas, ultimoParam) Devuelve el parámetro siguiente, o undefined si no hay más
getPreviousPageParam Lo mismo hacia atrás
fetchNextPage(), fetchPreviousPage() Pide otra página
hasNextPage getNextPageParam devolvió algo
isFetchingNextPage Se está pidiendo la siguiente, para distinguirlo de una recarga completa
isFetchNextPageError Falló la página siguiente, conservando las anteriores
maxPages Conserva como máximo N páginas en memoria

En la ejecución, las peticiones salieron en orden, con las páginas 1, 2 y 3, y la lista acabó con los 24 empleados. Si la ventana es alta y la primera página no llena la pantalla, el centinela ya es visible y se cargan más páginas sin hacer scroll, porque eso es lo que le has pedido al observador.

Lo que debes saber al recargar

Cuando una consulta infinita se recarga, todas sus páginas se piden de nuevo en orden, porque cada una podría haber cambiado y la siguiente depende de la anterior. Con tres páginas cargadas, una invalidación pidió las páginas 1, 2 y 3. Si tienes cincuenta, son cincuenta peticiones. Dos remedios:

  • maxPages limita las páginas en memoria, y por tanto las que se recargan. Si quieres poder volver a las descartadas, define también getPreviousPageParam.
  • Subir staleTime para que no se recargue al volver a la pestaña.

Además, la paginación por número sufre si se insertan filas mientras navegas, porque aparecen repetidas. Las API grandes usan cursores: cada respuesta dice “continúa desde aquí”. En Query solo cambia getNextPageParam, que devolvería algo como ultima.siguienteCursor.


11. Actualizaciones optimistas

Con una red real, cada acción tarda unos cientos de milisegundos. Si el botón no responde hasta entonces, la aplicación se siente lenta. Una actualización optimista muestra el resultado esperado de inmediato, apostando a que el servidor dirá que sí. Si dice que no, hay que deshacerlo.

Tiene sentido cuando el fallo es poco probable y fácil de deshacer: marcar como favorito, mover una tarjeta, cambiar un estado. No lo tiene en un pago: decirle a alguien que su pedido está hecho antes de saberlo es peor que hacerle esperar.

Forma 1: mostrar las variables

La forma más sencilla no toca la caché. Mientras la mutación está en curso, pinta sus variables como si ya estuvieran guardadas:

<ul>
  {lista.data?.map((d) => <li key={d.deptId}>{d.nombre}</li>)}
  {alta.isPending && <li className="atenuado">{alta.variables.nombre}</li>}
</ul>

Si la mutación falla, la fila desaparece sola y no hay nada que deshacer. Su límite es que solo funciona en el componente que tiene alta. Para verlo desde otro, useMutationState lee las mutaciones en curso de toda la aplicación, como hace el ejercicio 6.

Forma 2: escribir en la caché

Si el cambio debe verse en varias pantallas, hay que modificar la caché y prepararse para deshacer:

Diagrama de secuencia: la interfaz, la caché y el servidor durante una actualización optimista con éxito y con error

Figura 12. Primero se actualiza la caché; luego viaja la petición. Pase lo que pase, al final se pregunta al servidor.

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { peticion, type EstadoPedido, type Pagina, type Pedido } from './api'

const CLAVE = ['pedidos', 'pendientes']
const RUTA = '/pedidos?estado=PENDIENTE&tamano=6&orden=pedidoId'

interface Cambio {
  id: number
  estado: EstadoPedido
}

function useCambiarEstado(fallar: boolean) {
  const cliente = useQueryClient()

  return useMutation({
    mutationKey: ['cambiar-estado-pedido'],
    mutationFn: ({ id, estado }: Cambio) =>
      peticion<Pedido>(`/pedidos/${id}`, {
        method: 'PATCH',
        // Un estado inválido hace que la API responda 422
        body: JSON.stringify({ estado: fallar ? 'INVENTADO' : estado }),
      }),

    onMutate: async ({ id, estado }) => {
      // 1. Cancela recargas en curso: si llegaran ahora, pisarían el cambio optimista
      await cliente.cancelQueries({ queryKey: CLAVE })

      // 2. Guarda una copia para poder deshacer
      const anterior = cliente.getQueryData<Pagina<Pedido>>(CLAVE)

      // 3. Escribe en la caché lo que esperas que responda el servidor
      cliente.setQueryData<Pagina<Pedido>>(CLAVE, (datos) =>
        datos && {
          ...datos,
          contenido: datos.contenido.map((p) => (p.pedidoId === id ? { ...p, estado } : p)),
        },
      )

      // 4. Lo que devuelvas aquí llega a onError y onSettled
      return { anterior }
    },

    onError: (_error, _cambio, resultado) => {
      cliente.setQueryData(CLAVE, resultado?.anterior)
    },

    onSettled: () => {
      // Si quedan más mutaciones en vuelo, solo recarga la última
      if (cliente.isMutating({ mutationKey: ['cambiar-estado-pedido'] }) === 1) {
        return cliente.invalidateQueries({ queryKey: CLAVE })
      }
    },
  })
}

export default function App() {
  const [fallar, setFallar] = useState(false)
  const cambiar = useCambiarEstado(fallar)

  const pedidos = useQuery({
    queryKey: CLAVE,
    queryFn: ({ signal }) => peticion<Pagina<Pedido>>(RUTA, { signal }),
  })

  return (
    <section className="tarjeta">
      <h2>Pedidos por enviar</h2>
      <label className="fila">
        <input type="checkbox" checked={fallar} onChange={(e) => setFallar(e.target.checked)} />
        Simular un fallo del servidor
      </label>

      {pedidos.isPending && <p>Cargando...</p>}
      <table>
        <tbody>
          {pedidos.data?.contenido.map((p) => (
            <tr key={p.pedidoId}>
              <td>{p.pedidoId}</td>
              <td>{p.fechaPedido}</td>
              <td data-estado={p.pedidoId}>{p.estado}</td>
              <td className="numero">
                <button
                  disabled={p.estado !== 'PENDIENTE'}
                  onClick={() => cambiar.mutate({ id: p.pedidoId, estado: 'ENVIADO' })}
                >
                  Enviar
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {pedidos.data?.contenido.length === 0 && <p>No hay pedidos pendientes.</p>}
      {cambiar.isError && (
        <p className="error" role="alert">
          No se pudo enviar el pedido {cambiar.variables.id}: {cambiar.error.message}
        </p>
      )}
    </section>
  )
}

Medido con 800 ms de retardo en el PATCH:

Clic en Enviar (pedido 5022):   a los 100 ms ya se ve ENVIADO
Tras confirmar:                 la fila desaparece (ya no está pendiente)

Con el fallo simulado (5023):
A los 100 ms:                   ENVIADO
Tras el 422:                    PENDIENTE
Aviso:                          No se pudo enviar el pedido 5023: Hay datos no válidos

El pedido 5023 muestra ENVIADO antes de que el servidor responda

Figura 13. Fracción de segundo tras el clic: el servidor aún no ha contestado y la interfaz ya muestra el cambio.

El pedido 5023 vuelve a PENDIENTE y aparece un mensaje de error

Figura 14. El servidor rechaza el cambio: onError restaura la copia y se avisa al usuario.

Cada paso tiene un motivo:

  1. cancelQueries. Si había una recarga en marcha, su respuesta es anterior a tu cambio y lo pisaría. Por eso onMutate es async: hay que esperar a que se cancele. Para que la petición se aborte de verdad, tu función debe usar signal. Comprobado: el AbortSignal se dispara y la consulta queda en idle.
  2. getQueryData. Guarda la foto de antes, que se usará para deshacer.
  3. setQueryData. Aplica el cambio, de forma inmutable. La función actualizadora recibe undefined si aún no hay datos, y por eso aparece datos && {...}.
  4. return { anterior }. Lo que devuelve onMutate llega como tercer argumento a onError y onSettled.
  5. onSettled, no onSuccess. Se ejecuta en los dos casos. Tanto si acierta como si falla, se pregunta al servidor qué es verdad.

Varias mutaciones a la vez

Si el usuario pulsa dos botones seguidos, la primera mutación termina mientras la segunda sigue en vuelo. Si su onSettled invalidara la lista, la recarga traería datos sin el segundo cambio y la pantalla retrocedería un instante. Por eso el ejemplo solo invalida cuando es la última mutación en curso: cliente.isMutating(...) cuenta las pendientes, incluida la actual. Medido con dos clics rápidos:

PATCH /pedidos/5023
PATCH /pedidos/5024
GET   /pedidos?estado=PENDIENTE&tamano=6&orden=pedidoId     (una sola recarga)

12. DevTools

Query tiene sus propias herramientas de desarrollo, en un paquete aparte:

npm install @tanstack/react-query-devtools

Añádelas dentro del proveedor, en main.tsx:

import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

// ...
<QueryClientProvider client={clienteConsultas}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

Aparece un botón flotante en una esquina que abre el panel:

Panel de DevTools bajo la tabla, con tres claves de consulta y los contadores Fresh, Fetching, Paused, Stale e Inactive

Figura 15. Las DevTools sobre el ejemplo de paginación con los departamentos. La página 2 aparece inactiva: se ha precargado y nadie la usa todavía.

Lo que puedes hacer en él:

  • Ver todas las claves y su estado por colores: fresca, cargando, en pausa, obsoleta, inactiva. El número de la izquierda indica cuántos componentes están suscritos.
  • Abrir una entrada para ver sus datos, su dataUpdatedAt y las acciones Refetch, Invalidate, Reset y Remove.
  • Simular situaciones con Trigger Loading y Trigger Error, que fuerzan esos estados en una consulta, o con el icono que simula la falta de red. Es la forma más cómoda de probar tus estados de error sin tocar la API.
  • Ver las mutaciones en su pestaña.

Es la primera herramienta que debes abrir cuando algo no se recarga como esperas. Si ves dos entradas casi iguales, tienes un error en la clave. Si una consulta está siempre en verde cuando esperabas una recarga, revisa staleTime.

Las DevTools no llegan a producción. Solo se incluyen cuando el entorno es de desarrollo: en la compilación de este ejemplo, el módulo que las importa ocupó 0,42 kB y no había rastro de su código. No necesitas condicionar la importación.


13. Suspense

En el módulo 15 viste use(promesa) y la regla de que la promesa debe ser estable, algo difícil de conseguir a mano. Query resuelve ese problema: la caché es el lugar estable.

useSuspenseQuery es un useQuery que suspende mientras no hay datos y lanza el error a la Error Boundary más cercana. A cambio, data nunca es undefined, y los estados de carga y error salen del componente.

import { Component, Suspense, useState, useTransition, type ReactNode } from 'react'
import { QueryErrorResetBoundary, useSuspenseQueries } from '@tanstack/react-query'
import { peticion, type Departamento, type Empleado, type Pagina } from './api'

// Error Boundary mínima; el módulo 15 explica por qué sigue siendo una clase
class FronteraError extends Component<
  { alReiniciar: () => void; claveReinicio: unknown; children: ReactNode },
  { error: Error | null }
> {
  state: { error: Error | null } = { error: null }

  static getDerivedStateFromError(error: Error) {
    return { error }
  }

  // Cambiar de ficha también limpia el error
  componentDidUpdate(anteriores: { claveReinicio: unknown }) {
    if (this.state.error && anteriores.claveReinicio !== this.props.claveReinicio) {
      this.reiniciar()
    }
  }

  reiniciar() {
    this.props.alReiniciar() // Query vuelve a intentar las consultas falladas
    this.setState({ error: null }) // React vuelve a pintar a los hijos
  }

  render() {
    if (!this.state.error) return this.props.children

    return (
      <div role="alert">
        <p className="error">{this.state.error.message}</p>
        <button onClick={() => this.reiniciar()}>Reintentar</button>
      </div>
    )
  }
}

function FichaDepartamento({ id }: { id: number }) {
  // Las dos peticiones salen a la vez y el componente no sigue hasta tener ambas
  const [{ data: departamento }, { data: plantilla }] = useSuspenseQueries({
    queries: [
      {
        queryKey: ['departamentos', id],
        queryFn: ({ signal }) => peticion<Departamento>(`/departamentos/${id}`, { signal }),
        retry: false,
      },
      {
        queryKey: ['departamentos', id, 'plantilla'],
        queryFn: ({ signal }) =>
          peticion<Pagina<Empleado>>(`/empleados?deptId=${id}&tamano=1`, { signal }),
        retry: false,
      },
    ],
  })

  return (
    <p>
      <strong>{departamento.nombre}</strong>, en {departamento.localidad}. Plantilla:{' '}
      {plantilla.total}.
    </p>
  )
}

export default function App() {
  const [id, setId] = useState(10)
  const [cambiando, iniciarTransicion] = useTransition()

  return (
    <section className="tarjeta">
      <h2>Ficha de departamento</h2>
      <div className="fila">
        {[10, 20, 30, 999].map((n) => (
          <button key={n} onClick={() => iniciarTransicion(() => setId(n))}>
            {n === 999 ? 'Uno que no existe' : `Departamento ${n}`}
          </button>
        ))}
        {cambiando && <span className="discreto">Cargando...</span>}
      </div>

      <div className={cambiando ? 'atenuado' : ''}>
        <QueryErrorResetBoundary>
          {({ reset }) => (
            <FronteraError alReiniciar={reset} claveReinicio={id}>
              <Suspense fallback={<p>Cargando ficha...</p>}>
                <FichaDepartamento id={id} />
              </Suspense>
            </FronteraError>
          )}
        </QueryErrorResetBoundary>
      </div>
    </section>
  )
}

Ficha de departamento atenuada mientras carga la siguiente, con el indicador Cargando junto a los botones

Figura 16. Dentro de una transición, la ficha anterior sigue visible en lugar de saltar al fallback.

Mensaje de error Departamento 999 no encontrado con un botón Reintentar

Figura 17. La Error Boundary muestra el error y ofrece reintentar.

Medido al pulsar los botones, con 600 ms de retardo:

Cambio a departamento 20, a los 150 ms:
  ¿fallback visible?  no
  indicador           Cargando...
  se ve todavía       DIRECCIÓN, en Madrid. Plantilla: 1.
Departamento 999      Departamento 999 no encontrado
Reintentar            GET /departamentos/999  (solo la que falló)

Cuatro cosas que merece la pena fijar:

QueryErrorResetBoundary. Cuando una consulta falla, su error queda en la caché. Si la Error Boundary solo limpia su estado, el componente vuelve a renderizarse, lee el mismo error y vuelve a caer. reset() le dice a Query que en el próximo render lo intente otra vez. Hacen falta las dos cosas.

Transición al cambiar de dato. Sin startTransition, cambiar a un id nuevo suspendería y veríamos el fallback, porque esa es la entrada que no tiene datos. La transición mantiene lo anterior en pantalla, como aprendiste en el módulo 15.

No uses key en la frontera. La opción tentadora para limpiar el error es poner key={id} en FronteraError. Pero eso desmonta y crea un <Suspense> nuevo, y un <Suspense> recién creado muestra su fallback aunque estés en una transición. Por eso el ejemplo limpia el error en componentDidUpdate. Comprobado: añadiendo esa key, a los 150 ms del clic ya se ve “Cargando ficha…”; sin ella, no aparece.

Peticiones en serie. Dos useSuspenseQuery seguidos en un componente no van en paralelo: el primero suspende, el componente se detiene y el segundo no se llega a ejecutar hasta que el primero acaba. Es la cascada que viste en el módulo 15. useSuspenseQueries las lanza a la vez, y por eso es lo que usa el ejemplo. Este es el orden medido:

Dos useSuspenseQuery:   a sale, a llega, b sale, b llega
useSuspenseQueries:     x sale, y sale, x llega, y llega

Qué cambia respecto a useQuery

  useQuery useSuspenseQuery
data Puede ser undefined Siempre existe
Carga isPending <Suspense>
Error isError Error Boundary
enabled, skipToken Sí No: error de compilación
placeholderData Sí No: error de compilación
Consultas dependientes Con enabled Natural: la segunda espera a que la primera deje de suspender
staleTime mínimo El tuyo Query impone 1 s si pones menos

Que no exista enabled tiene lógica: un componente que no pide nada no tiene de qué suspender. Si la consulta es opcional, no renderices ese componente. El sustituto de placeholderData es justamente la transición. Los tres errores de tipo se han comprobado con @ts-expect-error.

El mínimo de staleTime existe porque un componente suspendido se desmonta y se vuelve a montar cuando llegan los datos. Con staleTime: 0, ese segundo montaje encontraría el dato obsoleto y pediría otra vez.

Para listas infinitas existe useSuspenseInfiniteQuery.


14. Query y React Router

Precargar en el loader 🆕 19.2

🆕 Desde React 19.2. React Router 8 no se instala en React 19.1 ni anteriores: exige react >= 19.2.7. Si usas una versión anterior: el patrón sigue siendo válido sin Router 8, porque solo consiste en llamar a queryClient.query antes de cambiar de pantalla: hazlo en el manejador que cambia de vista, o en onMouseEnter, y deja que el componente lea con useSuspenseQuery.

En el módulo 18 los loader pedían los datos y el componente los leía con useLoaderData. Query y Router no compiten: combinados, el loader se encarga de cuándo (antes de pintar la ruta) y Query de qué (caché, recargas, invalidación). El loader no devuelve datos, solo se asegura de que estén en la caché.

Necesitas react-router. Este ejemplo usa consultas.ts:

import { Suspense, useState } from 'react'
import { useQueryClient, useSuspenseQuery, type QueryClient } from '@tanstack/react-query'
import {
  createBrowserRouter,
  Link,
  Outlet,
  RouterProvider,
  useNavigation,
  useParams,
} from 'react-router'
import { detalleEmpleado, listaEmpleados } from './consultas'

const FILTRO = { tamano: 8 }

function Listado() {
  const cliente = useQueryClient()
  const { data } = useSuspenseQuery(listaEmpleados(FILTRO))

  return (
    <ul>
      {data.contenido.map((e) => (
        <li key={e.empId}>
          {/* Al pasar el ratón ya se deja la ficha en caché */}
          <Link
            to={`/empleados/${e.empId}`}
            onMouseEnter={() =>
              cliente.query({ ...detalleEmpleado(e.empId), staleTime: 60_000 }).catch(() => {})
            }
          >
            {e.nombre} {e.apellidos}
          </Link>
        </li>
      ))}
    </ul>
  )
}

function Ficha() {
  const id = Number(useParams().id)
  const { data: empleado } = useSuspenseQuery(detalleEmpleado(id))

  return (
    <>
      <h3>
        {empleado.nombre} {empleado.apellidos}
      </h3>
      <p>
        {empleado.oficio} en {empleado.departamento}
      </p>
      <Link to="/">Volver</Link>
    </>
  )
}

function Marco() {
  const navegacion = useNavigation()

  return (
    <section className="tarjeta">
      <h2>EMPRESA {navegacion.state === 'loading' && '(cargando...)'}</h2>
      <Suspense fallback={<p>Cargando...</p>}>
        <Outlet />
      </Suspense>
    </section>
  )
}

function crearRouter(cliente: QueryClient) {
  return createBrowserRouter([
    {
      element: <Marco />,
      HydrateFallback: () => <p>Cargando...</p>,
      children: [
        {
          path: '/',
          element: <Listado />,
          // No devuelve nada: su misión es dejar los datos en la caché
          loader: async () => {
            await cliente.query({ ...listaEmpleados(FILTRO), staleTime: 'static' })
          },
        },
        {
          path: '/empleados/:id',
          element: <Ficha />,
          loader: async ({ params }) => {
            await cliente.query({ ...detalleEmpleado(Number(params.id)), staleTime: 'static' })
          },
        },
      ],
    },
  ])
}

export default function App() {
  const cliente = useQueryClient()
  const [router] = useState(() => crearRouter(cliente))
  return <RouterProvider router={router} />
}

Lo medido en el navegador:

Al abrir la ruta '/':        GET /empleados?orden=empId&tamano=8
Al pasar el ratón por Luis:  GET /empleados/101
Al hacer clic:               ninguna petición nueva

La ficha aparece al instante, porque la petición se hizo durante el hover.

  • staleTime: 'static' en el loader significa “si ya hay algo en caché, no esperes a la red”. Así, volver atrás es instantáneo. Es el equivalente directo del antiguo ensureQueryData.
  • El staleTime del loader afecta solo a esa llamada, no a la consulta. Comprobado: tras query({ ...op, staleTime: 'static' }), un useQuery sin staleTime recarga al montar. Por eso la pantalla se actualiza si el dato era viejo.
  • El loader no se suscribe a nada. Es el useSuspenseQuery del componente quien mantiene la pantalla al día cuando se invalida.
  • HydrateFallback evita un aviso de Router mientras corre el primer loader.
  • El router necesita el cliente. Aquí se crea en un useState para que el ejemplo quepa en un fichero. En una aplicación grande, exporta el cliente de su módulo y crea el router fuera de los componentes.

15. Configuración global y errores

Valores por defecto

Es habitual configurar el cliente una vez y no repetir staleTime en cada consulta. Crea src/cliente.ts e impórtalo en main.tsx en lugar de crear el cliente allí:

import { QueryClient } from '@tanstack/react-query'
import { ApiError } from './api'

export const clienteConsultas = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000, // medio minuto fresco en toda la aplicación
      retry: (fallos, error) => {
        // Un 404 o un 403 no se arreglan insistiendo
        if (error instanceof ApiError && error.status < 500) return false
        return fallos < 2
      },
    },
    mutations: {
      onError: (error) => console.error('Mutación fallida:', error.message),
    },
  },
})

Con esta función de retry, medido con errores simulados:

Error 404: 1 intento
Error 500: 3 intentos

Cualquier opción de una consulta concreta tiene prioridad sobre estos valores, como retry: false en el ejemplo de Suspense.

Errores en un solo sitio

Si cada consulta debe mostrar un aviso cuando falla una recarga, repetirás lo mismo en cada componente. QueryCache y MutationCache aceptan callbacks globales, que se ejecutan una vez por consulta o mutación, sin importar cuántos componentes la usen. Este ejemplo crea su propio cliente para no tocar main.tsx:

import { useState } from 'react'
import {
  MutationCache,
  QueryCache,
  QueryClient,
  QueryClientProvider,
  useIsFetching,
  useMutation,
  useMutationState,
  useQuery,
  type QueryKey,
} from '@tanstack/react-query'
import { peticion, type Departamento, type Pagina } from './api'

function IndicadorGlobal() {
  const cargando = useIsFetching()
  const enviando = useMutationState({
    filters: { status: 'pending' },
    select: (mutacion) => mutacion.state.variables,
  })

  return (
    <p className="discreto" role="status">
      {cargando} consultas activas, {enviando.length} envíos en curso
    </p>
  )
}

function Departamentos() {
  const consulta = useQuery({
    queryKey: ['departamentos'],
    queryFn: ({ signal }) => peticion<Pagina<Departamento>>('/departamentos', { signal }),
  })

  const ampliar = useMutation({
    meta: { invalida: ['departamentos'] }, // la caché global leerá esto
    mutationFn: (d: Departamento) =>
      peticion<Departamento>(`/departamentos/${d.deptId}`, {
        method: 'PUT',
        body: JSON.stringify({ ...d, presupuesto: (d.presupuesto ?? 0) + 1000 }),
      }),
  })

  const borrar = useMutation({
    meta: { invalida: ['departamentos'] },
    mutationFn: (id: number) => peticion<void>(`/departamentos/${id}`, { method: 'DELETE' }),
  })

  return (
    <table>
      <tbody>
        {consulta.data?.contenido.map((d) => (
          <tr key={d.deptId}>
            <td>{d.nombre}</td>
            <td className="numero">{d.presupuesto?.toLocaleString('es-ES') ?? 'Sin asignar'}</td>
            <td className="numero">
              <button onClick={() => ampliar.mutate(d)}>+1000</button>{' '}
              <button onClick={() => borrar.mutate(d.deptId)}>Borrar</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

export default function App() {
  const [aviso, setAviso] = useState('')

  const [cliente] = useState(
    () =>
      new QueryClient({
        queryCache: new QueryCache({
          onError: (error, consulta) => {
            // Solo avisa si había datos en pantalla: una carga inicial ya pinta su error
            if (consulta.state.data !== undefined) {
              setAviso(`No se pudo actualizar: ${error.message}`)
            }
          },
        }),
        mutationCache: new MutationCache({
          onError: (error) => setAviso(error.message),
          onSuccess: (_datos, _variables, _resultado, mutacion, contexto) => {
            const clave = mutacion.meta?.invalida as QueryKey | undefined
            if (clave) return contexto.client.invalidateQueries({ queryKey: clave })
          },
        }),
      }),
  )

  return (
    <QueryClientProvider client={cliente}>
      <section className="tarjeta">
        <h2>Departamentos</h2>
        <IndicadorGlobal />
        <Departamentos />
        {aviso && (
          <p className="error" role="alert">
            {aviso}
          </p>
        )}
      </section>
    </QueryClientProvider>
  )
}

Lista de departamentos con los contadores de actividad y un aviso global de error

Figura 18. Borrar el departamento 10 responde 409, y el aviso sale de MutationCache, no del componente.

Aquí aparecen tres piezas más:

  • useIsFetching cuenta las consultas en marcha. Sirve para una barra de progreso global. Su pareja useIsMutating hace lo mismo con mutaciones.
  • useMutationState da acceso a las mutaciones de toda la aplicación, filtradas por estado o por clave.
  • meta es un objeto libre que acompaña a la consulta o mutación. Aquí declara qué hay que invalidar, y un único onSuccess global se encarga de hacerlo. Al pulsar +1000 se midió: PUT /departamentos/10 seguido de GET /departamentos.

El onError de QueryCache comprueba state.data para avisar solo en recargas. Los fallos de primera carga ya los pinta la pantalla, y dos mensajes para el mismo error sobran.

El tipo del error

Por defecto, error es de tipo Error, así que para leer status hay que usar instanceof. Si toda tu aplicación usa ApiError, puedes decirlo una vez, en un fichero src/query.d.ts:

import type { QueryKey } from '@tanstack/react-query'
import type { ApiError } from './api'

declare module '@tanstack/react-query' {
  interface Register {
    defaultError: ApiError
    mutationMeta: { invalida?: QueryKey }
  }
}

Con esto, consulta.error?.status compila sin comprobar nada, y meta.invalida deja de necesitar el as. Recuerda que es una promesa que le haces a TypeScript: un error de red de fetch sigue siendo un TypeError, no un ApiError. Por eso el resto del módulo prefiere instanceof.

Para las pruebas

El módulo 22 lo trata a fondo, pero anota dos reglas: crea un QueryClient nuevo por prueba, para que la caché de una no contamine a otra, y pon retry: false, porque si no una prueba de error tarda varios segundos en fallar.


16. Quién hace qué

Frente a Router y Actions

Después de los módulos 16 y 18 tienes varias formas de hablar con el servidor. No se excluyen:

Necesidad Herramienta
Datos que varias pantallas leen y deben estar al día Query
Cargar antes de pintar la ruta loader de Router, que llama a Query
Enviar un formulario sencillo y ver su estado pendiente Actions con useActionState
Una pantalla sin caché ni datos compartidos Un efecto puede bastar

Comparación con useOptimistic 🆕 19.0

🆕 Desde React 19.0. useOptimistic y useActionState no existen en React 18.3 ni anteriores. Si usas una versión anterior: usa onMutate de Query, como en la sección 11, o pinta las variables de la mutación; funcionan igual en React 18.

useOptimistic, del módulo 16, y las dos formas optimistas de Query resuelven lo mismo a distinta altura:

  useOptimistic variables de la mutación onMutate y caché
Alcance Un componente Un componente, o toda la aplicación con useMutationState Toda la aplicación
Deshacer Automático al acabar la transición Automático Lo escribes tú
Complejidad Baja Baja Alta
Dependencia React Query Query

Si ya usas Query, empieza por variables y recurre a onMutate cuando el cambio deba verse en más de un sitio.

Errores frecuentes

Síntoma Causa Solución
Un error 500 se guarda como dato bueno fetch no rechaza con 4xx y 5xx Comprueba respuesta.ok y lanza
Cambio un filtro y no pasa nada El filtro no está en la clave Todo lo que use queryFn va en queryKey
Cada render pierde la caché new QueryClient() dentro del componente Créalo fuera, o en useState
“Salen demasiadas peticiones” staleTime en 0 Sube staleTime; no desactives los disparadores
Se queda en “Cargando…” isPending con una consulta desactivada Usa isLoading
Un 404 tarda segundos en mostrarse Reintentos por defecto retry con función, como en la sección 15
Dos peticiones iguales en desarrollo Strict Mode y signal Normal: en producción sale una
Copio data a un useState y queda viejo Dos fuentes de verdad Usa data directamente, o deriva el valor con select
Lista sin actualizar tras una mutación No se invalida, o la clave no coincide Usa las claves de consultas.ts y mira las DevTools
El cambio optimista “parpadea” Una recarga en curso lo pisa await cancelQueries en onMutate
Mi callback no se ejecuta Va en mutate y el componente se desmontó Pásalo a useMutation
fetchNextPage nunca para getNextPageParam no devuelve undefined Devuelve undefined en la última página
El fallback salta aunque uso una transición Una key crea un <Suspense> nuevo Quita la key de la frontera

17. Ejercicios

Todos los ejercicios sustituyen a src/App.tsx y reutilizan src/api.ts y src/consultas.ts.

Ejercicio 1: Lista de departamentos

Muestra los departamentos en una tabla con su localidad y presupuesto, teniendo en cuenta que INVESTIGACIÓN tiene el presupuesto a null. Trata la carga y el error con un botón Reintentar, y añade un botón Actualizar que se desactive mientras recarga. Los datos deben darse por buenos un minuto.

Ver solución
import { useQuery } from '@tanstack/react-query'
import { peticion, type Departamento, type Pagina } from './api'

export default function App() {
  const consulta = useQuery({
    queryKey: ['departamentos', 'lista'],
    queryFn: ({ signal }) => peticion<Pagina<Departamento>>('/departamentos', { signal }),
    staleTime: 60_000,
  })

  if (consulta.isPending) return <p>Cargando departamentos...</p>

  if (consulta.isError) {
    return (
      <div role="alert">
        <p className="error">{consulta.error.message}</p>
        <button onClick={() => consulta.refetch()}>Reintentar</button>
      </div>
    )
  }

  return (
    <section className="tarjeta">
      <div className="fila">
        <h2>Departamentos</h2>
        <button disabled={consulta.isFetching} onClick={() => consulta.refetch()}>
          {consulta.isFetching ? 'Actualizando...' : 'Actualizar'}
        </button>
      </div>
      <table>
        <tbody>
          {consulta.data.contenido.map((d) => (
            <tr key={d.deptId}>
              <td>{d.nombre}</td>
              <td>{d.localidad}</td>
              <td className="numero">
                {d.presupuesto === null ? 'Sin asignar' : `${d.presupuesto.toLocaleString('es-ES')} €`}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  )
}

Actualizar usa isFetching y no isPending, porque en una recarga ya hay datos. refetch ignora staleTime: pides la recarga expresamente, así que sale a la red aunque el dato sea fresco. La última fila muestra “Sin asignar”.

Ejercicio 2: Panel en vivo

Muestra los datos de GET /api/estadisticas: número de empleados y de pedidos, facturación, y el nombre del departamento con mayor masa salarial. Debe actualizarse solo cada 5 segundos y mostrar la hora de la última actualización. Calcula el departamento mayor con select, sin recalcularlo en cada render.

Ver solución
import { useQuery } from '@tanstack/react-query'
import { peticion } from './api'

interface Estadisticas {
  empleados: number
  pedidos: number
  facturacion: number
  porDepartamento: { deptId: number; nombre: string; masaSalarial: number }[]
}

function resumir(datos: Estadisticas) {
  const mayor = datos.porDepartamento.reduce((a, b) => (b.masaSalarial > a.masaSalarial ? b : a))
  return { ...datos, mayor: mayor.nombre }
}

export default function App() {
  const panel = useQuery({
    queryKey: ['estadisticas'],
    queryFn: ({ signal }) => peticion<Estadisticas>('/estadisticas', { signal }),
    refetchInterval: 5000, // cada 5 s, y solo con la pestaña visible
    select: resumir, // función estable: no se recalcula en cada render
  })

  return (
    <section className="tarjeta">
      <h2>Panel de mando</h2>
      {panel.data && (
        <>
          <p>
            {panel.data.empleados} empleados, {panel.data.pedidos} pedidos,{' '}
            {panel.data.facturacion.toLocaleString('es-ES')} € facturados.
          </p>
          <p>Mayor masa salarial: {panel.data.mayor}.</p>
          <p className="discreto">
            Actualizado a las {new Date(panel.dataUpdatedAt).toLocaleTimeString('es-ES')}
          </p>
        </>
      )}
    </section>
  )
}

Resultado real:

24 empleados, 24 pedidos, 61.617,46 € facturados.
Mayor masa salarial: INFORMÁTICA.

Al estar resumir fuera del componente, es estable y select solo se ejecuta cuando llegan datos distintos. refetchInterval se detiene solo con la pestaña oculta. dataUpdatedAt cambia en cada recarga aunque los datos sean idénticos.

Ejercicio 3: Buscador con espera

Haz un buscador de empleados que no lance una petición por cada tecla. Solo debe buscar con dos letras o más, esperando 300 ms desde la última tecla, y sin vaciar los resultados mientras llegan los nuevos.

Ver solución
import { useEffect, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { listaEmpleados } from './consultas'

function useValorRetrasado<T>(valor: T, ms: number) {
  const [retrasado, setRetrasado] = useState(valor)

  useEffect(() => {
    const temporizador = setTimeout(() => setRetrasado(valor), ms)
    return () => clearTimeout(temporizador)
  }, [valor, ms])

  return retrasado
}

export default function App() {
  const [texto, setTexto] = useState('')
  const busqueda = useValorRetrasado(texto.trim(), 300)

  const resultado = useQuery({
    ...listaEmpleados({ q: busqueda, tamano: 10 }),
    enabled: busqueda.length >= 2,
    placeholderData: keepPreviousData,
  })

  return (
    <section className="tarjeta">
      <h2>Buscador de empleados</h2>
      <input
        aria-label="Buscar empleados"
        placeholder="Al menos dos letras..."
        value={texto}
        onChange={(e) => setTexto(e.target.value)}
      />
      {busqueda.length < 2 && <p className="discreto">Escribe para buscar.</p>}
      {resultado.isError && <p className="error">{resultado.error.message}</p>}
      {busqueda.length >= 2 && (
        <ul className={resultado.isPlaceholderData ? 'atenuado' : ''}>
          {resultado.data?.contenido.map((e) => (
            <li key={e.empId}>
              {e.nombre} {e.apellidos}
            </li>
          ))}
        </ul>
      )}
      {resultado.data?.total === 0 && <p>Sin resultados.</p>}
    </section>
  )
}

Al teclear “gar” con 50 ms entre letras, salió una única petición: GET /empleados?orden=empId&tamano=10&q=gar. El estado texto es de cliente y busqueda alimenta la clave. useValorRetrasado es la misma idea que el useDebounce del módulo 12. Sin él, signal cancelaría las peticiones anteriores, pero seguirían saliendo.

Ejercicio 4: Tres consultas encadenadas

Un selector de clientes. Al elegir uno, se ven sus pedidos como botones (GET /api/clientes/:id/pedidos). Al pulsar uno, se ven sus líneas y el total (GET /api/pedidos/:id). Cambiar de cliente debe olvidar el pedido elegido.

Ver solución
import { useState } from 'react'
import { skipToken, useQuery } from '@tanstack/react-query'
import { peticion, type Pagina, type Pedido } from './api'

interface Cliente {
  clienteId: number
  nombre: string
}

interface PedidoConLineas extends Pedido {
  total: number
  lineas: { numLinea: number; producto: string; cantidad: number }[]
}

export default function App() {
  const [clienteId, setClienteId] = useState<number | null>(null)
  const [pedidoId, setPedidoId] = useState<number | null>(null)

  const clientes = useQuery({
    queryKey: ['clientes'],
    queryFn: ({ signal }) => peticion<Pagina<Cliente>>('/clientes?tamano=100', { signal }),
    select: (pagina) => pagina.contenido,
  })

  const pedidos = useQuery({
    queryKey: ['clientes', clienteId, 'pedidos'],
    queryFn:
      clienteId === null
        ? skipToken
        : ({ signal }) => peticion<Pagina<Pedido>>(`/clientes/${clienteId}/pedidos`, { signal }),
    select: (pagina) => pagina.contenido,
  })

  const detalle = useQuery({
    queryKey: ['pedidos', pedidoId],
    queryFn:
      pedidoId === null
        ? skipToken
        : ({ signal }) => peticion<PedidoConLineas>(`/pedidos/${pedidoId}`, { signal }),
  })

  function elegirCliente(valor: string) {
    setClienteId(valor ? Number(valor) : null)
    setPedidoId(null) // el pedido elegido era de otro cliente
  }

  return (
    <section className="tarjeta">
      <h2>Pedidos de un cliente</h2>
      <select
        aria-label="Cliente"
        value={clienteId ?? ''}
        onChange={(e) => elegirCliente(e.target.value)}
      >
        <option value="">Elige un cliente</option>
        {clientes.data?.map((c) => (
          <option key={c.clienteId} value={c.clienteId}>
            {c.nombre}
          </option>
        ))}
      </select>

      {pedidos.isLoading && <p>Cargando pedidos...</p>}
      <div className="fila" style={{ marginTop: '0.75rem' }}>
        {pedidos.data?.map((p) => (
          <button key={p.pedidoId} onClick={() => setPedidoId(p.pedidoId)}>
            {p.pedidoId} ({p.estado})
          </button>
        ))}
      </div>

      {detalle.isLoading && <p>Cargando líneas...</p>}
      {detalle.data && (
        <>
          <ul>
            {detalle.data.lineas.map((l) => (
              <li key={l.numLinea}>
                {l.cantidad} x {l.producto}
              </li>
            ))}
          </ul>
          <p>Total: {detalle.data.total.toLocaleString('es-ES')} €</p>
        </>
      )}
    </section>
  )
}

Con el primer cliente y su primer pedido, salen 5001 con 1 x Portátil Gaming 16, 2 x Ratón inalámbrico y 2 x Monitor 24 FHD, por un total de 1802,9 €. No se usa useEffect para encadenar: cada consulta depende de una variable de estado, y Query reacciona al cambio de clave. El setPedidoId(null) va en el manejador, como recomienda el módulo 09.

Ejercicio 5: Editar sin recargar de más

Muestra una lista de empleados con su salario. Al pulsar uno, aparece su ficha con un botón “Subir 100 €”. La ficha debe verse enseguida, con lo que ya sabe la lista, mientras llega el detalle completo. Tras guardar, escribe la respuesta en la caché del detalle, sin volver a pedirlo, y recarga las listas.

Ver solución
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { peticion, type Empleado } from './api'
import { claves, detalleEmpleado, listaEmpleados } from './consultas'

const FILTRO = { tamano: 5 }

function useCambiarSalario() {
  const cliente = useQueryClient()

  return useMutation({
    mutationFn: (empleado: Empleado) =>
      peticion<Empleado>(`/empleados/${empleado.empId}`, {
        method: 'PUT',
        body: JSON.stringify(empleado),
      }),
    onSuccess: (actualizado) => {
      // La respuesta ya es el dato nuevo: nos ahorramos una petición
      cliente.setQueryData(claves.detalle(actualizado.empId), actualizado)
      // Las listas pueden verse afectadas: que se recarguen
      return cliente.invalidateQueries({ queryKey: claves.listas() })
    },
  })
}

function Ficha({ id }: { id: number }) {
  const cliente = useQueryClient()
  const ficha = useQuery({
    ...detalleEmpleado(id),
    // Mientras llega el detalle, muestra lo que ya sabe la lista
    placeholderData: () =>
      cliente
        .getQueryData(listaEmpleados(FILTRO).queryKey)
        ?.contenido.find((e) => e.empId === id),
  })
  const cambiar = useCambiarSalario()

  if (!ficha.data) return <p>Cargando ficha...</p>
  const empleado = ficha.data

  return (
    <div>
      <h3>
        {empleado.nombre} {empleado.apellidos}
      </h3>
      <p id="salario">Salario: {empleado.salario} €</p>
      <button
        disabled={cambiar.isPending || ficha.isPlaceholderData}
        onClick={() => cambiar.mutate({ ...empleado, salario: empleado.salario + 100 })}
      >
        {cambiar.isPending ? 'Guardando...' : 'Subir 100 €'}
      </button>
      {cambiar.isError && <p className="error">{cambiar.error.message}</p>}
    </div>
  )
}

export default function App() {
  const [id, setId] = useState<number | null>(null)
  const lista = useQuery(listaEmpleados(FILTRO))

  return (
    <section className="tarjeta">
      <h2>Salarios</h2>
      <ul>
        {lista.data?.contenido.map((e) => (
          <li key={e.empId}>
            <button onClick={() => setId(e.empId)}>
              {e.nombre}: {e.salario} €
            </button>
          </li>
        ))}
      </ul>
      {id !== null && <Ficha id={id} />}
    </section>
  )
}

Secuencia real al subir el salario de Luis de 4800 € a 4900 €:

PUT /empleados/101
GET /empleados?orden=empId&tamano=5

No hay GET /empleados/101: el detalle viene de la respuesta del PUT. El botón está desactivado mientras la ficha es placeholderData, porque el empleado de la lista podría estar incompleto. Fíjate en que getQueryData conoce el tipo sin indicarlo, gracias a queryOptions.

Ejercicio 6: Alta visible desde otro componente

Separa el formulario de alta de departamentos y la lista en dos componentes hermanos, sin compartir props. Mientras se guarda, la lista debe mostrar el nuevo departamento atenuado, con “(guardando…)”.

Ver solución
import { useState } from 'react'
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { peticion, type Departamento, type Pagina } from './api'

type NuevoDepartamento = Pick<Departamento, 'nombre' | 'localidad'>

const CLAVE = ['departamentos']
const ALTA = ['alta-departamento']

function Formulario() {
  const cliente = useQueryClient()
  const [nombre, setNombre] = useState('')

  const alta = useMutation({
    mutationKey: ALTA,
    mutationFn: (nuevo: NuevoDepartamento) =>
      peticion<Departamento>('/departamentos', { method: 'POST', body: JSON.stringify(nuevo) }),
    onSettled: () => cliente.invalidateQueries({ queryKey: CLAVE }),
  })

  return (
    <form
      className="fila"
      onSubmit={(evento) => {
        evento.preventDefault()
        alta.mutate({ nombre, localidad: 'Madrid' })
        setNombre('')
      }}
    >
      <input
        aria-label="Nombre"
        placeholder="Nombre del departamento"
        value={nombre}
        onChange={(e) => setNombre(e.target.value)}
      />
      <button>Crear</button>
      {alta.isError && <span className="error">{alta.error.message}</span>}
    </form>
  )
}

function Lista() {
  const departamentos = useQuery({
    queryKey: CLAVE,
    queryFn: ({ signal }) => peticion<Pagina<Departamento>>('/departamentos', { signal }),
  })

  // Lee las mutaciones en curso aunque se hayan lanzado desde otro componente
  const pendientes = useMutationState({
    filters: { mutationKey: ALTA, status: 'pending' },
    select: (mutacion) => mutacion.state.variables as NuevoDepartamento,
  })

  return (
    <ul>
      {departamentos.data?.contenido.map((d) => <li key={d.deptId}>{d.nombre}</li>)}
      {pendientes.map((d) => (
        <li key={d.nombre} className="atenuado">
          {d.nombre} (guardando...)
        </li>
      ))}
    </ul>
  )
}

export default function App() {
  return (
    <section className="tarjeta">
      <h2>Departamentos</h2>
      <Formulario />
      <Lista />
    </section>
  )
}

Al crear uno con 500 ms de retardo, la lista muestra a los 150 ms CALIDAD (guardando...), y después CALIDAD, ya real. La mutationKey es el nexo entre los dos componentes. Si el servidor falla, la mutación deja de estar pending y la fila desaparece sin escribir nada para deshacer.

Ejercicio 7: Pedidos con “Cargar más”

Lista los pedidos de 5 en 5, con su cliente y total, un botón “Cargar más” y un filtro por estado. Muestra cuántos llevas de cuántos.

Ver solución
import { useState } from 'react'
import { useInfiniteQuery } from '@tanstack/react-query'
import { peticion, type EstadoPedido, type Pagina, type Pedido } from './api'

const ESTADOS: EstadoPedido[] = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO']

interface PedidoConTotal extends Pedido {
  cliente: string
  total: number
}

export default function App() {
  const [estado, setEstado] = useState<EstadoPedido | ''>('')

  const pedidos = useInfiniteQuery({
    queryKey: ['pedidos', 'infinita', estado],
    queryFn: ({ pageParam, signal }) => {
      const parametros = new URLSearchParams({ pagina: String(pageParam), tamano: '5' })
      if (estado) parametros.set('estado', estado)
      return peticion<Pagina<PedidoConTotal>>(`/pedidos?${parametros}`, { signal })
    },
    initialPageParam: 1,
    getNextPageParam: (ultima) => (ultima.pagina < ultima.paginas ? ultima.pagina + 1 : undefined),
  })

  const lista = pedidos.data?.pages.flatMap((p) => p.contenido) ?? []
  const total = pedidos.data?.pages[0].total ?? 0

  return (
    <section className="tarjeta">
      <h2>Pedidos</h2>
      <select
        aria-label="Estado"
        value={estado}
        onChange={(e) => setEstado(e.target.value as EstadoPedido | '')}
      >
        <option value="">Todos</option>
        {ESTADOS.map((e) => (
          <option key={e}>{e}</option>
        ))}
      </select>

      {pedidos.isPending && <p>Cargando...</p>}
      <table>
        <tbody>
          {lista.map((p) => (
            <tr key={p.pedidoId}>
              <td>{p.pedidoId}</td>
              <td>{p.cliente}</td>
              <td>{p.estado}</td>
              <td className="numero">{p.total.toLocaleString('es-ES')} €</td>
            </tr>
          ))}
        </tbody>
      </table>
      <div className="fila" style={{ marginTop: '0.75rem' }}>
        <button
          disabled={!pedidos.hasNextPage || pedidos.isFetchingNextPage}
          onClick={() => pedidos.fetchNextPage()}
        >
          {pedidos.isFetchingNextPage ? 'Cargando...' : 'Cargar más'}
        </button>
        <span className="discreto">
          {lista.length} de {total}
        </span>
      </div>
    </section>
  )
}

Resultado real: 5 de 24, tras pulsar 10 de 24, y filtrando por ENTREGADO 5 de 18. El estado va en la clave, así que cada filtro tiene su propia lista paginada y volver a uno anterior es instantáneo. Sin este botón no hace falta IntersectionObserver.

Ejercicio 8: Borrar con deshacer

Lista los departamentos con un botón Borrar por fila que quite el departamento de forma optimista, restaure la fila si la API responde 409 y muestre su mensaje. Compruébalo con DIRECCIÓN, que tiene empleados, y con INVESTIGACIÓN, que no los tiene.

Ver solución
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { peticion, type Departamento, type Pagina } from './api'

const CLAVE = ['departamentos', 'lista']

function useBorrarDepartamento() {
  const cliente = useQueryClient()

  return useMutation({
    mutationFn: (id: number) => peticion<void>(`/departamentos/${id}`, { method: 'DELETE' }),

    onMutate: async (id) => {
      await cliente.cancelQueries({ queryKey: CLAVE })
      const anterior = cliente.getQueryData<Pagina<Departamento>>(CLAVE)

      cliente.setQueryData<Pagina<Departamento>>(CLAVE, (datos) =>
        datos && {
          ...datos,
          total: datos.total - 1,
          contenido: datos.contenido.filter((d) => d.deptId !== id),
        },
      )

      return { anterior }
    },

    onError: (_error, _id, resultado) => cliente.setQueryData(CLAVE, resultado?.anterior),
    onSettled: () => cliente.invalidateQueries({ queryKey: CLAVE }),
  })
}

export default function App() {
  const borrar = useBorrarDepartamento()
  const consulta = useQuery({
    queryKey: CLAVE,
    queryFn: ({ signal }) => peticion<Pagina<Departamento>>('/departamentos', { signal }),
    staleTime: 60_000,
  })

  return (
    <section className="tarjeta">
      <h2>Departamentos ({consulta.data?.total})</h2>
      <table>
        <tbody>
          {consulta.data?.contenido.map((d) => (
            <tr key={d.deptId}>
              <td>{d.nombre}</td>
              <td className="numero">
                <button onClick={() => borrar.mutate(d.deptId)}>Borrar</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {borrar.isError && (
        <p className="error" role="alert">
          {borrar.error.message}
        </p>
      )}
    </section>
  )
}

Medido con 500 ms de retardo en el DELETE: al pulsar en DIRECCIÓN, a los 120 ms hay 7 filas y el título dice Departamentos (7). Tras el 409 vuelven las 8 filas y sale “No se puede borrar un departamento con empleados”, que llega como error.message porque peticion lo copia del cuerpo de la respuesta. INVESTIGACIÓN se borra de verdad. Como total también se ha tocado, el título cambia con la fila.

Ejercicio 9 (propuesto): Detalle de pedido con Suspense

Convierte el detalle del ejercicio 4 en un componente con useSuspenseQuery, dentro de una Error Boundary con QueryErrorResetBoundary. Precarga el pedido al pasar el ratón por el botón, y usa useTransition para que no aparezca el fallback al cambiar de pedido.

Ejercicio 10 (propuesto): Paginación en la URL

Rehaz el ejemplo de la sección 9 guardando la página en la URL con useSearchParams, como en el módulo 18. Añade un loader que deje la página en caché. Comprueba que recargar el navegador en la página 3 funciona, y que Atrás devuelve a la página anterior sin peticiones.


Resumen

Concepto API Idea clave
Estado de servidor Caché en QueryClient Es una copia que caduca, no un estado tuyo
Proveedor QueryClientProvider Un cliente estable, creado fuera de los componentes
Leer datos useQuery({ queryKey, queryFn }) La clave nombra el dato; la función sabe obtenerlo
Cancelar signal en queryFn Pásalo a fetch; Query lo activa cuando deja de interesar
Estados status, fetchStatus Qué datos tengo y qué hace la petición
Primera carga isPending, isLoading isLoading también es falso si está desactivada
Frescura staleTime (0) Cuándo volver a preguntar
Limpieza gcTime (5 min) Cuánto se guarda sin usarse
Recargas refetchOnWindowFocus, refetchInterval Sube staleTime antes de desactivarlas
Reintentos retry 3 en consultas, 0 en mutaciones; usa una función para no insistir con 4xx
Claves queryOptions, invalidateQueries Jerárquicas; se invalidan por prefijo
Dependientes skipToken, enabled No hay consulta hasta tener lo necesario
Transformar select No cambia la caché; mejor una función estable
Paralelas useQueries, combine Para un número variable de consultas
Escribir datos useMutation, mutate Solo se ejecuta cuando la lanzas
Tras escribir invalidateQueries, setQueryData Devuelve la promesa para seguir pendiente
Callbacks useMutation frente a mutate Lo que debe ocurrir siempre va en useMutation
Paginar placeholderData: keepPreviousData Conserva lo anterior; isPlaceholderData lo indica
Precargar queryClient.query Sustituye a prefetchQuery, fetchQuery y ensureQueryData
Infinito useInfiniteQuery, getNextPageParam Una clave con muchas páginas; se recargan todas
Optimista sencillo variables, useMutationState Sin tocar la caché, sin deshacer
Optimista completo onMutate, cancelQueries, onError, onSettled Cancela, copia, escribe, deshaz, invalida
DevTools ReactQueryDevtools No llegan a producción
Suspense useSuspenseQuery, useSuspenseQueries data siempre existe; sin enabled
Reintentar errores QueryErrorResetBoundary La frontera y la caché deben reiniciarse
Router loader con query El loader llena la caché; el componente la lee
Global QueryCache, MutationCache, meta Un solo sitio para avisos e invalidación
Tipos Register Fija defaultError para toda la aplicación