En el capítulo 6 cargabas los héroes con tres useState y un useEffect. Funcionaba. Pero si lo piensas un momento, habías reimplementado a mano algo que cualquier app necesita: saber si hay datos, si están cargando y si hubo un error. Y eso era solo la parte visible. Faltaba la caché, el reintento automático, la deduplicación de peticiones y la revalidación cuando los datos quedan obsoletos.
Esto ocurre porque hay dos tipos de estado muy distintos, y useState solo está pensado para uno de ellos.
Server-state frente a client-state#
El client-state es estado que solo existe en tu navegador: si un modal está abierto, qué texto lleva un filtro, el héroe que el usuario acaba de seleccionar. Tú lo creas, tú lo controlas. useState es la herramienta correcta para él.
El server-state es distinto. Los héroes del Team Builder, los pedidos de un e-commerce, el perfil de un usuario: esos datos no te pertenecen. Viven en un servidor. Lo que tienes es una copia en caché de lo que el servidor tenía en el momento de la petición. Y esa copia puede quedar desactualizada en cuanto otro usuario modifica algo, en cuanto pasa el tiempo suficiente, en cuanto el usuario deja la pestaña y vuelve.
Tratar el server-state como si fuera client-state —meterlo en useState y cargarlo con useEffect— te obliga a reimplementar por tu cuenta:
- Caché (para no pedir lo mismo dos veces).
- Deduplicación (si dos componentes piden los mismos datos a la vez, deberían lanzar una sola petición).
- Revalidación (detectar que los datos han caducado y actualizarlos).
- Reintento (si la petición falla por un corte puntual, volver a intentarlo).
- Estado de fondo (mostrar los datos viejos mientras se actualizan los nuevos, en vez de un spinner que bloquea).
Eso es mucho código que escribir, mantener y fallar. TanStack Query lo hace por ti.
TanStack Query: la capa que faltaba#
TanStack Query (antes llamada React Query) añade una capa de caché entre tu componente y el servidor. Los datos viven en esa caché identificados por una queryKey. Cuando un componente pide datos, la librería mira si ya los tiene y si están frescos: si es así, los devuelve al instante sin petición. Si no, lanza la petición y, cuando resuelve, actualiza la caché.
El setup mínimo son dos cosas:
- Un
QueryClient(el objeto que gestiona la caché), creado a nivel de módulo, fuera de cualquier componente. Si lo crearas dentro, se recrearía en cada render y la caché se perdería: no habría caché. - Un
QueryClientProviderque envuelva los componentes que usanuseQuery, igual que un context Provider.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// A nivel de módulo: se crea una sola vez para toda la vida de la app.
const queryClient = new QueryClient();
export default function App() {
return (
// Todos los useQuery de los hijos hablarán con este cliente.
<QueryClientProvider client={queryClient}>
<Roster />
</QueryClientProvider>
);
}Sin el QueryClientProvider, un useQuery dentro de un componente hijo lanzará un error: no sabe a qué caché conectarse.
useQuery: una línea que sustituye tres estados y un efecto#
Con el Provider en su sitio, cargar datos es esto:
import { useQuery } from "@tanstack/react-query";
function Roster() {
// queryKey: el "nombre" de esta consulta en la caché.
// Debe ser un array. ["heroes"] identifica estos datos de forma única.
// queryFn: la función que obtiene los datos; tiene que devolver una promesa.
// data es Heroe[] | undefined (undefined hasta que la query resuelve); con "= []"
// le damos un array vacío por defecto, así que heroes siempre es un array.
const { data: heroes = [], isLoading, isError } = useQuery({
queryKey: ["heroes"],
queryFn: cargarHeroes,
});
// isLoading es true mientras la promesa no resuelve y no hay datos en caché.
if (isLoading) return <p className="cargando">Cargando héroes...</p>;
// isError es true si la promesa rechaza (fallo de red, error del servidor...).
if (isError) return <p className="error">Error al cargar los héroes.</p>;
// Aquí ya no cargamos ni hay error: heroes tiene los datos.
return (
<div className="lista">
{heroes.map((heroe) => (
<article key={heroe.id} className="tarjeta">
<h2 className="tarjeta__nombre">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
</article>
))}
</div>
);
}Eso es todo. No hay useState, no hay useEffect, no hay que gestionar la bandera de carga ni capturar errores con .catch. Lo que en el capítulo 6 eran tres estados y un efecto, aquí es una llamada que devuelve tres valores.
Y por debajo está haciendo más cosas que el código del capítulo 6 no hacía:
- Si dos componentes distintos llaman a
useQueryconqueryKey: ["heroes"], TanStack Query lanza una sola petición y comparte el resultado entre los dos. ConuseEffect, cada componente lanzaría su propia petición. - Si la petición falla, la vuelve a intentar tres veces antes de reportar el error.
- Si el usuario deja la pestaña y vuelve, revalida los datos en segundo plano.
La queryKey y por qué es un array#
La queryKey identifica una entrada en la caché. ["heroes"] y ["heroes", { rol: "Daño" }] son dos entradas distintas. Eso permite cachear por parámetros:
// Esta query tiene su propia entrada en caché, distinta de ["heroes"].
// Si el usuario cambia de rol, TanStack Query pide los datos nuevos o los saca de caché si ya los tiene.
const { data } = useQuery({
queryKey: ["heroes", { rol: filtroActivo }],
queryFn: () => cargarHeroesPorRol(filtroActivo),
});En el ejercicio de este capítulo, el filtro de rol va en useState (es client-state: solo existe en el navegador) y se aplica sobre los datos ya cargados. Si el filtro viniera del servidor —que devolviera héroes distintos según el rol— iría en la queryKey.
Stale, fresco y revalidación#
Cuando una query resuelve, TanStack Query guarda los datos en caché. ¿Cuánto tiempo son “frescos”? Lo controla staleTime (por defecto, 0 milisegundos: inmediatamente obsoletos).
Con staleTime: 0, en cuanto el componente remonta o la ventana recibe el foco, TanStack Query vuelve a pedir los datos. Parece agresivo, pero la experiencia es buena: muestra los datos viejos al instante (sin spinner) y actualiza en segundo plano. No bloquea la UI.
Con staleTime más alto, los datos se reutilizan desde caché durante ese tiempo sin petición:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Los datos se consideran frescos durante 30 segundos.
// Si el componente remonta antes de ese tiempo, salen de caché sin petición.
staleTime: 30_000,
},
},
});Cuánto staleTime poner depende de con qué frecuencia cambian los datos en tu caso concreto. Para datos que cambian poco (configuración, catálogos), puede ser minutos. Para datos en tiempo real (precios, posiciones), quizá 0.
Para saber si hay una petición en vuelo aunque ya haya datos en caché, usa isFetching en vez de isLoading. La diferencia: isLoading es true solo cuando no hay datos en absoluto y se está cargando. isFetching es true siempre que haya una petición en curso, incluso si ya hay datos mostrados.
Pruébalo: useQuery básico#
El demo muestra el mismo Team Builder del capítulo 6, pero sin ningún useEffect ni useState para la carga. Observa que aparece “Cargando…” durante 800ms y luego el grid; ese comportamiento viene de isLoading. Prueba a cambiar cargarHeroes para que rechace (Promise.reject(new Error("fallo"))) y verás el mensaje de error.
Pruébalo: caché y staleTime#
Este demo añade un botón para desmontar y remontar el componente Roster, simulando una navegación a otra pantalla y vuelta. Un contador muestra cuántas peticiones reales se han lanzado.
Con staleTime: 10_000 (10 segundos): desmonta y vuelve a montar antes de 10 segundos. El contador no sube: los datos salieron de caché sin petición y el componente se montó al instante. Espera más de 10 segundos y vuelve a montar: el contador sube porque los datos habían caducado.
useMutation: cuando el cambio va al servidor#
useQuery es para leer datos. Cuando quieres cambiar algo en el servidor —añadir un héroe al equipo, borrar un pedido, actualizar un perfil—, usa useMutation.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function Roster() {
const qc = useQueryClient();
const mutacion = useMutation({
// mutationFn: la función que realiza el cambio en el servidor.
mutationFn: anadirHeroeApi,
// onSuccess se ejecuta cuando la promesa resuelve.
// Aquí invalidamos la caché de heroes para que useQuery vuelva a pedir los datos.
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["heroes"] });
},
// onError se ejecuta si la promesa rechaza.
onError: () => {
console.log("Error al añadir el héroe");
},
});
return (
<button
// Mientras la mutación está en vuelo, isPending es true.
// Desactivar el botón evita que el usuario lance dos peticiones iguales.
disabled={mutacion.isPending}
onClick={() => mutacion.mutate(heroeSeleccionado)}
>
{mutacion.isPending ? "Añadiendo..." : "Añadir al equipo"}
</button>
);
}El flujo es: el usuario hace clic → mutacion.mutate(datos) lanza la petición → mientras espera, isPending es true y puedes deshabilitar el botón o mostrar un indicador → si resuelve, onSuccess corre y normalmente invalidas la caché para que los datos se refresquen → si rechaza, onError corre y puedes mostrar un error.
invalidateQueries marca una entrada de caché como obsoleta para que useQuery la vuelva a pedir en la próxima oportunidad. Es la forma idiomática de “decirle a TanStack Query que los datos del servidor han cambiado, ve a buscarlos de nuevo”.
Pruébalo: caché, refetch e invalidación en vivo#
Hasta aquí lo has leído; ahora compruébalo. Explicar una librería sin que puedas verla funcionar es media enseñanza. Este panel monta un useQuery con staleTime: 5000 (5 segundos) y muestra tres valores en vivo: isLoading, isFetching y un contador de peticiones reales lanzadas a la “red” (cada llamada a queryFn lo incrementa). Tiene dos botones:
- Refetch llama a
query.refetch(), un método que viene en el resultado deuseQuery. Fuerza una petición nueva aunque los datos sean frescos (aún dentro delstaleTime):isFetchingsalta atruey el contador sube. Es lo que quieres cuando el usuario pulsa explícitamente “actualizar”. - Invalidar caché llama a
qc.invalidateQueries({ queryKey: ["heroes"] }): marca la entrada como obsoleta, así queuseQueryvuelve a pedir en cuanto puede. El efecto visible se parece al de refetch, pero el mecanismo es distinto: refetch ordena “pide ya”; invalidar dice “estos datos ya no valen, repíntalos cuando toque”. La diferencia importa cuando hay varias pantallas observando la mismaqueryKey: invalidar las refresca todas; unrefetch()solo afecta a la query desde la que lo llamas.
Fíjate en que isLoading solo es true la primera vez, cuando aún no hay datos. En las recargas posteriores se queda en false y es isFetching el que se enciende, porque ya hay datos en pantalla mientras se piden los nuevos. Esa es, en vivo, la diferencia entre los dos que viste arriba: por eso pintas el grid con isLoading (la primera carga) y avisas de un refresco en segundo plano con isFetching.
Cuándo NO usar TanStack Query#
TanStack Query es para server-state: datos que vienen de un servidor y pueden cambiar fuera de tu control. No uses useQuery para:
- Estado de UI: si un panel está abierto o cerrado, qué pestaña está activa, el texto de un input. Eso es client-state:
useState. - Estado global compartido: si necesitas que varios componentes de la app compartan un contador o una preferencia del usuario que no viene del servidor,
useContexto Zustand (capítulo anterior) son la herramienta correcta. - Datos derivados: si algo se puede calcular a partir de los datos que ya tienes, calcúlalo en el render. No hagas una
useQuerypara obtener el winrate promedio si ya tienes los héroes.
RTK Query: la alternativa en el ecosistema Redux#
Si tu app ya usa Redux Toolkit (capítulo 10), tiene una solución de server-state integrada: RTK Query. En vez de configurar un QueryClient, defines los endpoints directamente en el store y RTK Query genera los hooks automáticamente:
// Con RTK Query (conceptual — no lo monta en el playground porque necesita un store Redux):
// Se define un "servicio" que describe los endpoints de la API.
const heroesApi = createApi({
reducerPath: "heroesApi",
baseQuery: fetchBaseQuery({ baseUrl: "https://api.tuequipo.dev" }),
endpoints: (builder) => ({
// getHeroes es el nombre del endpoint; builder.query es para lectura.
getHeroes: builder.query<Heroe[], void>({ query: () => "/heroes" }),
}),
});
// RTK Query genera el hook automáticamente a partir del nombre del endpoint.
const { data: heroes, isLoading } = heroesApi.useGetHeroesQuery();La diferencia filosófica con TanStack Query: RTK Query vive dentro del store de Redux (los datos de la caché son parte del estado global), lo que facilita combinarlos con slices normales de Redux. TanStack Query es independiente del store y más ligero de configurar si no usas Redux.
¿Cuál elegir? Si tu app ya tiene Redux Toolkit, RTK Query evita añadir una dependencia más y mantiene toda la lógica en el mismo sistema. Si empiezas de cero o no usas Redux, TanStack Query es más sencillo de poner en marcha y su API es más ergonómica para la mayoría de casos.
Comprueba lo que sabes#
Pregunta 1 de 4
¿Cuál es la diferencia entre server-state y client-state?
Tu turno#
Ejercicio · en esta página
Cargar los héroes con TanStack Query
Sustituye el patrón useState + useEffect del capítulo 6 por useQuery de TanStack Query. El resultado debe cargar los héroes con caché y mostrar los estados de carga y error.
Paso 1: useQuery básico
- QueryClient creado a nivel de módulo (fuera del componente) y envuelto en QueryClientProvider
- useQuery con queryKey: ['heroes'] y queryFn: cargarHeroes
- Muestra 'Cargando héroes...' con isLoading y 'Error al cargar los héroes.' con isError
- Grid de tarjetas cuando data tiene valor; winrate calculado en el render
Paso 2: staleTime y filtro de rol
- QueryClient configurado con staleTime de al menos 30 segundos
- Filtro de rol (todos / Daño / Apoyo / Tanque) como useState local — no en la query
- Los héroes filtrados se calculan en el render como dato derivado
- Estado vacío si no hay héroes con el rol seleccionado
Paso 3: useMutation, ranking y mobile-first
- useMutation para añadir héroes al equipo; isPending bloquea el botón mientras espera
- Reglas de negocio: sin duplicados, máximo 6 héroes en el equipo
- El equipo se ordena por winrate descendente en el render (dato derivado, no estado)
- Componente TarjetaHeroe separado con props tipadas
- La UI aguanta a 375px sin romperse: tarjetas con flex-wrap, texto no desbordado
Ver soluciones
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const HEROES: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Simula la API: una promesa que resuelve tras 800ms, como la red de verdad.
function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(HEROES), 800);
});
}
// El cliente de caché: va a nivel de módulo para no recrearse en cada render.
const queryClient = new QueryClient();
function RosterQuery() {
// useQuery sustituye por completo el patrón useState + useEffect del capítulo anterior.
// queryKey identifica esta consulta en la caché: ["heroes"] es su "nombre".
// queryFn es la función que obtiene los datos; debe devolver una promesa.
const { data: heroes = [], isLoading, isError } = useQuery({
queryKey: ["heroes"],
queryFn: cargarHeroes,
});
// Mientras la promesa no resuelve, isLoading es true.
if (isLoading) {
return <p className="cargando">Cargando héroes...</p>;
}
// Si la promesa rechaza, isError es true.
if (isError) {
return <p className="error">Error al cargar los héroes.</p>;
}
return (
<div className="lista">
{/* data puede ser undefined hasta que resuelva; aquí ya sabemos que tiene valor. */}
{heroes.map((heroe) => {
// El winrate es un dato derivado: se calcula en el render, no se guarda en estado.
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
return (
<article key={heroe.id} className="tarjeta">
<h2 className="tarjeta__nombre">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__winrate">{winrate}%</p>
</article>
);
})}
</div>
);
}
// QueryClientProvider hace el cliente disponible para todos los useQuery de su subárbol.
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<section>
<h1 className="titulo">Team Builder</h1>
<RosterQuery />
</section>
</QueryClientProvider>
);
} Por qué este nivel
- El patrón del capítulo 6 eran tres useState (heroes, cargando, error) + un useEffect que los rellenaba manualmente. useQuery reemplaza todo eso en una llamada: te da data, isLoading e isError sin que tengas que gestionarlos. La caché, la deduplicación y el reintento vienen incluidos. No es solo menos código: es menos código que puede tener bugs.
- QueryClient a nivel de módulo es fundamental. Si lo crearas dentro del componente, se recrearía en cada render y la caché se perdería: cada render sería como arrancar de cero.
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
import { useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const HEROES: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Simula la API con un retraso de 800ms.
function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(HEROES), 800);
});
}
// staleTime: cuánto tiempo (en ms) los datos se consideran frescos.
// Durante ese tiempo, useQuery no vuelve a lanzar la petición aunque el componente remonte.
// Aquí: 30 segundos. Si cambias de pantalla y vuelves antes de 30s, los datos salen al instante.
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
});
function RosterQuery() {
// Estado de UI local: el filtro de rol. No va en useQuery porque no es server-state.
// Es un filtro que solo existe en el cliente; no viene del servidor.
const [filtroRol, setFiltroRol] = useState<string>("todos");
const { data: heroes = [], isLoading, isError } = useQuery({
queryKey: ["heroes"],
queryFn: cargarHeroes,
});
if (isLoading) {
return <p className="cargando">Cargando héroes...</p>;
}
if (isError) {
return <p className="error">Error al cargar los héroes.</p>;
}
// El filtrado es un dato derivado: se calcula en el render a partir del estado local y los datos.
// No se guarda en un estado aparte para no desincronizarlo con heroes y filtroRol.
const heroesFiltrados =
filtroRol === "todos"
? heroes
: heroes.filter((h) => h.rol === filtroRol);
return (
<div>
<div className="acciones">
{/* Cada botón cambia el estado local de filtro; no afecta a la caché de la query. */}
{["todos", "Daño", "Apoyo", "Tanque"].map((rol) => (
<button
key={rol}
className={filtroRol === rol ? "boton" : "boton boton--secundario"}
onClick={() => setFiltroRol(rol)}
>
{rol}
</button>
))}
</div>
<br />
<div className="lista">
{heroesFiltrados.map((heroe) => {
// Dato derivado: el winrate se calcula en el render.
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
return (
<article key={heroe.id} className="tarjeta">
<h2 className="tarjeta__nombre">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__winrate">{winrate}%</p>
</article>
);
})}
{heroesFiltrados.length === 0 && (
<p className="equipo-vacio">No hay héroes con ese rol.</p>
)}
</div>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<section>
<h1 className="titulo">Team Builder</h1>
<RosterQuery />
</section>
</QueryClientProvider>
);
} Por qué es mejor que el anterior
- staleTime en 30 segundos significa que si navegas a otra pantalla y vuelves antes de ese tiempo, los datos salen de caché al instante — sin spinner, sin petición. Después de 30 segundos, TanStack Query los vuelve a pedir en segundo plano mientras muestra los datos viejos. Esa combinación (instantáneo + actualización silenciosa) es lo que diferencia la experiencia de una app con TanStack Query de una que bloquea con spinner en cada visita.
- El filtro de rol es client-state: solo existe en el navegador y no tiene nada que ver con lo que devuelve el servidor. Meterlo en la queryKey o en un useState aparte para luego sincronizarlo con los datos sería sobre-ingeniería. Se calcula en el render como un dato derivado del estado local y de los datos de la query: el patrón más simple que funciona.
import {
QueryClient,
QueryClientProvider,
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useState } from "react";
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
const HEROES: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
{ id: 5, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
{ id: 6, nombre: "Sigma", rol: "Tanque", partidas: 70, victorias: 41 },
];
// Simula la API de carga.
function cargarHeroes(): Promise<Heroe[]> {
return new Promise((resolve) => {
setTimeout(() => resolve(HEROES), 800);
});
}
// Simula la API de mutación: añadir un héroe al equipo en el servidor.
// En un proyecto real, esto sería un POST; aquí resuelve siempre en 400ms.
function anadirHeroeApi(heroe: Heroe): Promise<Heroe> {
return new Promise((resolve) => {
setTimeout(() => resolve(heroe), 400);
});
}
// staleTime de 30 segundos: la caché no caduca a la primera navegación.
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
});
// Props del componente de tarjeta: el héroe y el callback para añadirlo.
interface TarjetaProps {
heroe: Heroe;
enEquipo: boolean;
onAnadir: (h: Heroe) => void;
onQuitar: (id: number) => void;
pendiente: boolean;
}
// TarjetaHeroe es un componente separado con props tipadas.
// Se reutiliza aquí; si hubiera otras pantallas con tarjetas, iría allí también.
function TarjetaHeroe({ heroe, enEquipo, onAnadir, onQuitar, pendiente }: TarjetaProps) {
// El winrate es un dato derivado: se calcula donde se necesita, no en un estado extra.
const winrate = Math.round((heroe.victorias / heroe.partidas) * 100);
return (
<article className="tarjeta">
<h2 className="tarjeta__nombre">{heroe.nombre}</h2>
<p className="tarjeta__rol">{heroe.rol}</p>
<p className="tarjeta__winrate">{winrate}%</p>
<div className="acciones">
<button
className={enEquipo ? "boton boton--secundario" : "boton"}
// Si hay una mutación en vuelo (pendiente), el botón no acepta más clics.
disabled={pendiente}
onClick={() => (enEquipo ? onQuitar(heroe.id) : onAnadir(heroe))}
>
{pendiente ? "..." : enEquipo ? "Quitar" : "Añadir"}
</button>
</div>
</article>
);
}
function RosterQuery() {
// Estado local de UI: el filtro de rol y el equipo seleccionado.
// Son client-state: no vienen del servidor; useQuery no les corresponde.
const [filtroRol, setFiltroRol] = useState<string>("todos");
const [equipo, setEquipo] = useState<Heroe[]>([]);
// useQueryClient da acceso al cliente para poder invalidar o actualizar la caché.
const qc = useQueryClient();
// useQuery para cargar el roster del servidor.
const { data: heroes = [], isLoading, isError } = useQuery({
queryKey: ["heroes"],
queryFn: cargarHeroes,
});
// useMutation para la acción de añadir: llama a la API y, al terminar, actualiza la caché.
// onSuccess se ejecuta cuando la promesa resuelve; onError si rechaza.
const mutacion = useMutation({
mutationFn: anadirHeroeApi,
onSuccess: (heroeAnadido) => {
// Después de añadir con éxito, actualizamos el equipo local.
// En una app real, aquí haríamos queryClient.invalidateQueries para refrescar la lista del servidor.
setEquipo((prev) => [...prev, heroeAnadido]);
},
});
// Quitar no hace petición al servidor en este ejercicio: solo actualiza el estado local.
function quitar(id: number) {
setEquipo((prev) => prev.filter((h) => h.id !== id));
// En una app real: mutationFn de borrado + invalidateQueries.
qc.invalidateQueries({ queryKey: ["heroes"] });
}
if (isLoading) {
return <p className="cargando">Cargando héroes...</p>;
}
if (isError) {
return <p className="error">Error al cargar los héroes.</p>;
}
// El filtrado y el ranking son datos derivados: se calculan en el render.
const heroesFiltrados =
filtroRol === "todos"
? heroes
: heroes.filter((h) => h.rol === filtroRol);
// El equipo se ordena por winrate descendente: datos derivados del estado, no un estado aparte.
const equipoOrdenado = [...equipo].sort(
(a, b) => b.victorias / b.partidas - a.victorias / a.partidas
);
return (
<div>
<div className="seccion">
<h2 className="seccion__titulo">Roster</h2>
<div className="acciones">
{["todos", "Daño", "Apoyo", "Tanque"].map((rol) => (
<button
key={rol}
className={filtroRol === rol ? "boton" : "boton boton--secundario"}
onClick={() => setFiltroRol(rol)}
>
{rol}
</button>
))}
</div>
<br />
<div className="lista">
{heroesFiltrados.map((heroe) => (
<TarjetaHeroe
key={heroe.id}
heroe={heroe}
enEquipo={equipo.some((h) => h.id === heroe.id)}
// Bloquea el botón mientras la mutación de ese héroe está en vuelo.
pendiente={mutacion.isPending && mutacion.variables.id === heroe.id}
onAnadir={(h) => {
// Regla de negocio: máximo 6 y sin duplicados.
if (equipo.length >= 6) return;
if (equipo.some((e) => e.id === h.id)) return;
mutacion.mutate(h);
}}
onQuitar={quitar}
/>
))}
</div>
</div>
<div className="seccion">
<h2 className="seccion__titulo">Mi equipo ({equipo.length} / 6)</h2>
{equipoOrdenado.length === 0 ? (
<p className="equipo-vacio">El equipo está vacío.</p>
) : (
<ul className="equipo-lista">
{equipoOrdenado.map((h) => {
const winrate = Math.round((h.victorias / h.partidas) * 100);
return (
<li key={h.id} className="equipo-item">
<span>{h.nombre} — {h.rol} — {winrate}%</span>
<button className="boton boton--secundario" onClick={() => quitar(h.id)}>
Quitar
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<section style={{ padding: "16px" }}>
<h1 className="titulo">Team Builder</h1>
<RosterQuery />
</section>
</QueryClientProvider>
);
} Por qué es mejor que el anterior
- useMutation modela la escritura: mientras la promesa está en vuelo, isPending es true y el botón se desactiva para evitar dobles envíos. Si la promesa rechaza, onError puede mostrar un error y revertir el estado local. Es el mismo concepto de 'UI optimista' del capítulo 9, pero con la infraestructura de TanStack Query manejando el ciclo de vida.
- TarjetaHeroe como componente separado con props tipadas tiene dos beneficios concretos: el componente padre no necesita saber cómo se pinta una tarjeta (separación de responsabilidades), y si en otra pantalla necesitas mostrar una tarjeta, la reutilizas sin copiar código.
- El ranking del equipo se deriva en el render con [...equipo].sort(...): no se guarda en un estado adicional. Si lo guardaras en estado, tendrías que sincronizarlo manualmente cada vez que el equipo cambia. Un estado de más es una fuente de desincronización más.