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
useEffectconfetch - Leer datos con
useQueryy controlar sus estados constatusyfetchStatus - 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
skipTokeny lanzar consultas en paralelo conuseQueries - Escribir datos con
useMutation, invalidar la caché y tratar los errores de la API - Paginar sin parpadeos, precargar con
queryClient.queryy hacer listas infinitas conuseInfiniteQuery - 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
loaderde 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 conuseOptimistic. Los ejemplos usan@tanstack/react-query5.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
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-queryes 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:
fetchsolo rechaza su promesa cuando falla la red. Un 404 o un 500 son respuestas válidas parafetch, 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 compruebasrespuesta.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>
)
}

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.
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:
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:
staleTimeresponde a “¿debo volver a preguntar?”. Vale0por defecto.gcTimeresponde 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 |

Figura 5. El laboratorio tras la secuencia anterior. Las líneas dobles son efecto de Strict Mode y desaparecen en producción.
✏️ Ejercicio: Cambia
gcTimeaInfinityy repite la secuencia, esperando más de 7 s: el dato sigue ahí. Cambia luegostaleTimeaInfinityy 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 sustaleTime.
¿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:
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>
)
}

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 asignal. isLoading, noisPending. Sin departamento la consulta está enpending, pero no hace nada.data = []al desestructurar evita comprobarundefineden 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>
)
}

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>
)
}

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:
SubmitEventes el tipo recomendado en@types/react19.3, que marcaFormEventcomo obsoleto, como se contó en el módulo 11. Si tu@types/reactes anterior y no lo tiene, usaFormEvent<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
setQueryDatacomo 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>
)
}

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.isPlaceholderDataindica 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 unpaginasque aún no es el de la página pedida.placeholderDatanunca se guarda en la caché. Es un disfraz temporal para la pantalla. Se diferencia deinitialData, 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_000en la precarga. Sin él,queryClient.queryconsidera obsoleto cualquier dato y pediría otra vez la página a cada visita..catch(() => {}).queryClient.querydevuelve 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,fetchQueryyensureQueryData. 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 esqueryClient.query(opciones), y en consultas infinitasqueryClient.infiniteQuery(opciones). Las equivalencias son:prefetchQuery(op)pasa a serquery(op).catch(noop), yensureQueryData(op)pasa a serquery({ ...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>
)
}

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:
maxPageslimita las páginas en memoria, y por tanto las que se recargan. Si quieres poder volver a las descartadas, define tambiéngetPreviousPageParam.- Subir
staleTimepara 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:
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

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

Figura 14. El servidor rechaza el cambio: onError restaura la copia y se avisa al usuario.
Cada paso tiene un motivo:
cancelQueries. Si había una recarga en marcha, su respuesta es anterior a tu cambio y lo pisaría. Por esoonMutateesasync: hay que esperar a que se cancele. Para que la petición se aborte de verdad, tu función debe usarsignal. Comprobado: elAbortSignalse dispara y la consulta queda enidle.getQueryData. Guarda la foto de antes, que se usará para deshacer.setQueryData. Aplica el cambio, de forma inmutable. La función actualizadora recibeundefinedsi aún no hay datos, y por eso aparecedatos && {...}.return { anterior }. Lo que devuelveonMutatellega como tercer argumento aonErroryonSettled.onSettled, noonSuccess. 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:

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
dataUpdatedAty 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>
)
}

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

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 aqueryClient.queryantes de cambiar de pantalla: hazlo en el manejador que cambia de vista, o enonMouseEnter, y deja que el componente lea conuseSuspenseQuery.
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 elloadersignifica “si ya hay algo en caché, no esperes a la red”. Así, volver atrás es instantáneo. Es el equivalente directo del antiguoensureQueryData.- El
staleTimedelloaderafecta solo a esa llamada, no a la consulta. Comprobado: trasquery({ ...op, staleTime: 'static' }), unuseQuerysinstaleTimerecarga al montar. Por eso la pantalla se actualiza si el dato era viejo. - El
loaderno se suscribe a nada. Es eluseSuspenseQuerydel componente quien mantiene la pantalla al día cuando se invalida. HydrateFallbackevita un aviso de Router mientras corre el primerloader.- El router necesita el cliente. Aquí se crea en un
useStatepara 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>
)
}

Figura 18. Borrar el departamento 10 responde 409, y el aviso sale de MutationCache, no del componente.
Aquí aparecen tres piezas más:
useIsFetchingcuenta las consultas en marcha. Sirve para una barra de progreso global. Su parejauseIsMutatinghace lo mismo con mutaciones.useMutationStateda acceso a las mutaciones de toda la aplicación, filtradas por estado o por clave.metaes un objeto libre que acompaña a la consulta o mutación. Aquí declara qué hay que invalidar, y un únicoonSuccessglobal se encarga de hacerlo. Al pulsar +1000 se midió:PUT /departamentos/10seguido deGET /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.
useOptimisticyuseActionStateno existen en React 18.3 ni anteriores. Si usas una versión anterior: usaonMutatede Query, como en la sección 11, o pinta lasvariablesde 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 |