learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

Data fetching y server-state

TanStack Query y RTK Query: caché, revalidación y estado del servidor bien gestionado.

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:

  1. 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é.
  2. Un QueryClientProvider que envuelva los componentes que usan useQuery, igual que un context Provider.
tsx
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:

tsx
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 useQuery con queryKey: ["heroes"], TanStack Query lanza una sola petición y comparte el resultado entre los dos. Con useEffect, 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:

tsx
// 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:

tsx
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.

tsx
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 de useQuery. Fuerza una petición nueva aunque los datos sean frescos (aún dentro del staleTime): isFetching salta a true y 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í que useQuery vuelve 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 misma queryKey: invalidar las refresca todas; un refetch() 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, useContext o 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 useQuery para 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:

tsx
// 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
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.