wandres.dev
REDUX TOOLKIT · slices y RTK Query

RTK Query: el estado de servidor dentro de Redux

RTK Query es la capa de data-fetching integrada en Redux Toolkit: defines endpoints en createApi con un baseQuery, y RTK genera hooks de React con cache, deduplicación de peticiones, reintento y estados de carga listos. Esta lección explica cómo los endpoints query y mutation producen hooks tipados, cómo la cache se indexa por endpoint y argumentos con caducidad configurable, la diferencia entre isLoading e isFetching, y sobre todo el sistema de etiquetas providesTags e invalidatesTags que hace que una mutación refresque automáticamente las queries afectadas. Cierra con la idea que reordena todo el nivel: el estado de servidor no es estado de cliente, es una cache con dueño en otra parte, y nombrarlo así es lo que RTK Query aporta a Redux.

⏱ 18 min

Al final de la lección de createAsyncThunk quedó una sospecha en el aire: si repites el trío pending, fulfilled, rejected para cada lectura de datos del servidor, estás construyendo a mano una cache. RTK Query es la conclusión de esa sospecha llevada hasta el final. En vez de escribir thunks y reducers para cada endpoint, declaras los endpoints una sola vez en createApi, y RTK genera por ti la cache completa: hooks de React que piden los datos, los guardan indexados, deduplican peticiones idénticas, marcan los estados de carga y error, y reejecutan la consulta cuando algo la invalida. El estado de servidor deja de vivir en slices escritos a mano y pasa a vivir en una cache que sabe caducar, deduplicar e invalidar por su cuenta, todo dentro del mismo store de Redux que ya tienes.

🎯 Al terminar esta lección sabrás
  • Definir endpoints query y mutation en createApi y consumir los hooks tipados que RTK genera.
  • Entender cómo la cache se indexa por endpoint y argumentos, y distinguir isLoading de isFetching.
  • Invalidar y refrescar datos con el sistema de etiquetas providesTags e invalidatesTags.
  • Reconocer el estado de servidor como una cache y decidir entre RTK Query y TanStack Query.

createApi: endpoints que generan hooks

Un createApi reúne toda la comunicación con un backend en un solo lugar. Le das un reducerPath donde vivirá su cache, un baseQuery que sabe hacer las peticiones —fetchBaseQuery cubre el caso común con una baseUrl y cabeceras— y un mapa de endpoints. Cada endpoint es una query de lectura o una mutation de escritura, y por cada uno RTK genera un hook con nombre derivado: listarPosts produce useListarPostsQuery, crearPost produce useCrearPostMutation. No escribes ni acciones ni reducers ni thunks; describes los endpoints y RTK fabrica la maquinaria.

import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

interface Post { id: string; titulo: string }
interface NuevoPost { titulo: string }

export const api = createApi({
  reducerPath: "api",
  baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
  tagTypes: ["Post"],
  endpoints: (builder) => ({
    listarPosts: builder.query<Post[], void>({
      query: () => "posts",
      providesTags: ["Post"],
    }),
    crearPost: builder.mutation<Post, NuevoPost>({
      query: (cuerpo) => ({ url: "posts", method: "POST", body: cuerpo }),
      invalidatesTags: ["Post"],
    }),
  }),
});

export const { useListarPostsQuery, useCrearPostMutation } = api;

El único cableado en el store es registrar el reducer de la api y su middleware, y activar los listeners globales que refrescan al recuperar el foco o reconectar.

import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import { api } from "./api";

export const store = configureStore({
  reducer: { [api.reducerPath]: api.reducer },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware),
});

setupListeners(store.dispatch); // refetch al volver el foco o reconectar
📥

builder.query

Para leer. Genera un hook useXQuery que cachea, deduplica y refresca. Declara qué provee con providesTags.

📤

builder.mutation

Para escribir. Genera un hook useXMutation con un disparador. Declara qué invalida con invalidatesTags.

Cache, deduplicación y ciclo de la query

El hook de una query devuelve un objeto con todo lo que la UI necesita: data cuando llega, error si falla, y las banderas isLoading, isFetching e isSuccess. La cache se indexa por el nombre del endpoint más los argumentos serializados, así que dos componentes que pidan listarPosts con los mismos argumentos comparten una sola entrada y una sola petición: RTK deduplica. Cuando nadie usa una entrada, un temporizador la conserva un tiempo —keepUnusedDataFor, sesenta segundos por defecto— y luego la descarta. La distinción entre isLoading e isFetching es fina pero importante: isLoading es cierto solo la primera vez, cuando no hay datos aún; isFetching es cierto en cualquier recarga, también cuando ya muestras datos viejos mientras llegan los nuevos.

function ListaPosts() {
  const { data, isLoading, isFetching, error } = useListarPostsQuery();
  const [crear, { isLoading: creando }] = useCrearPostMutation();

  if (isLoading) return <p>cargando por primera vez</p>;
  if (error) return <p>algo fallo</p>;

  return (
    <div>
      {isFetching && <span>actualizando en segundo plano</span>}
      <ul>{data?.map((p) => <li key={p.id}>{p.titulo}</li>)}</ul>
      <button disabled={creando} onClick={() => crear({ titulo: "nuevo" })}>
        crear
      </button>
    </div>
  );
}
💡
isLoading contra isFetching: la carga honesta

Confundir las dos banderas produce dos malas experiencias opuestas. Si usas isLoading para todo, la primera carga muestra el spinner pero las recargas cambian los datos de golpe sin avisar, y el usuario no entiende por qué la lista saltó. Si usas isFetching para todo, cada recarga en segundo plano borra la vista y planta un spinner sobre datos que eran perfectamente utilizables. La UI honesta usa isLoading para el vacío inicial —cuando de verdad no hay nada que mostrar— y isFetching para una señal discreta de fondo mientras se refresca lo que ya está en pantalla. Mostrar datos viejos mientras llegan los nuevos no es un truco: es el comportamiento correcto de una cache.

Etiquetas: la invalidación automática

El sistema de etiquetas es lo que convierte a RTK Query de un fetcher en una cache coherente. Una query declara qué etiquetas provee con providesTags; una mutation declara qué etiquetas invalida con invalidatesTags. Cuando una mutation termina con éxito, RTK marca como obsoletas todas las queries que proveían las etiquetas invalidadas y las reejecuta automáticamente. Así, crear un post invalida la etiqueta Post, la lista de posts se marca obsoleta y se recarga sola, sin que escribas una línea de sincronización. Es la coherencia entre escritura y lectura resuelta de forma declarativa.

flowchart TD
A[useListarPostsQuery] --> B[cache etiquetada Post]
C[useCrearPostMutation] -->|invalidatesTags Post| B
B -->|etiqueta invalidada| D[refetch automatico]
D --> A
style B fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b

Para que la escritura se sienta instantánea antes incluso de que el servidor responda, onQueryStarted permite una actualización optimista: parcheas la cache al vuelo con updateQueryData y, si el servidor falla, deshaces el parche con una sola llamada.

crearPost: builder.mutation<Post, NuevoPost>({
  query: (cuerpo) => ({ url: "posts", method: "POST", body: cuerpo }),
  async onQueryStarted(cuerpo, { dispatch, queryFulfilled }) {
    const parche = dispatch(
      api.util.updateQueryData("listarPosts", undefined, (borrador) => {
        borrador.push({ id: "temporal", titulo: cuerpo.titulo });
      }),
    );
    try {
      await queryFulfilled;
    } catch {
      parche.undo(); // el servidor fallo: revierte el optimismo
    }
  },
}),
⚠️
RTK Query o TanStack Query, no las dos

RTK Query y TanStack Query resuelven el mismo problema —el estado de servidor como cache— con filosofías distintas. RTK Query vive dentro del store de Redux, comparte sus DevTools y su middleware, y brilla cuando ya usas Redux para el estado de cliente y quieres una sola fuente de verdad. TanStack Query es independiente del store, más ligero si no tienes Redux, y hoy es el estándar de facto fuera del mundo Redux. Elegir es una decisión de arquitectura, no de gusto: si el proyecto ya es Redux, RTK Query evita una segunda librería de cache; si no lo es, meter Redux entero solo para tener RTK Query rara vez se justifica. Lo que no debes hacer es correr las dos a la vez sobre los mismos datos.

El estado de servidor no es estado de cliente: es una cache prestada

La idea que reordena todo este nivel es una distinción que durante años estuvo borrosa y que RTK Query obliga a nombrar. Buena parte de lo que la gente guardaba en Redux nunca fue estado de cliente: era una copia de datos cuyo dueño real vive en el servidor, traída al navegador y condenada desde ese instante a quedarse obsoleta. Y esa diferencia de propiedad lo cambia todo. Del estado de cliente eres dueño: tú lo creas, tú lo mutas, y la última escritura es la verdad. Del estado de servidor solo eres inquilino: lo tomas prestado, otro lo puede cambiar sin avisarte, y tu copia es verdadera solo hasta que deja de serlo. Por eso los problemas difíciles del estado de servidor nunca fueron los reducers ni las acciones —esos son problemas de estado de cliente— sino la caducidad, la deduplicación de peticiones, el refresco al recuperar el foco, la invalidación cuando una escritura vuelve obsoleta una lectura, la reconciliación entre lo que crees tener y lo que el servidor tiene de verdad. Escribir un slice a mano para datos de servidor es responder con las herramientas del inquilino equivocado: modelas la propiedad que no tienes y dejas sin resolver la caducidad que sí sufres. RTK Query acierta porque parte de la premisa correcta: no es un store, es una cache, y una cache asume desde el principio que sus datos tienen dueño en otra parte y pueden envejecer. Cuando interiorizas esta distinción, tu store de Redux encoge de golpe: expulsas de él todo lo que era servidor disfrazado de cliente, lo entregas a una cache que sabe tratarlo, y lo que queda dentro —el estado de cliente puro, el que de verdad posees y compartes entre vistas— es tan poco y tan claro que por fin entiendes para qué servía Redux en realidad.

⚔️ Convierte thunks de lectura en una cache
  1. Toma un slice donde manejabas a mano el trío status, datos y error para leer datos del servidor, y reescríbelo como un endpoint query en createApi; cuenta el código que desaparece.
  2. Consume el hook generado en dos componentes hermanos con los mismos argumentos y comprueba en la pestaña de red que RTK deduplica en una sola petición.
  3. Añade una mutation con invalidatesTags y una query con providesTags sobre la misma etiqueta; ejecuta la mutación y observa la query recargarse sola sin código de sincronización.
  4. Distingue en la UI isLoading de isFetching: spinner solo en el vacío inicial y una señal discreta en las recargas de fondo sobre datos ya visibles.
  5. Implementa una actualización optimista con onQueryStarted y updateQueryData, y fuerza un fallo del servidor para ver parche.undo revertir el cambio.
  6. Argumenta para tu proyecto real la elección entre RTK Query y TanStack Query, y enumera qué queda en tu store una vez expulsado todo el estado de servidor.