wandres.dev
RTK QUERY A FONDO · cache y tags

Tags de cache: el grafo de invalidación

Las etiquetas son el corazón conceptual de RTK Query y lo que lo distingue de un simple envoltorio de fetch. Esta lección las trata como lo que son: la construcción explícita de un grafo dirigido entre lecturas y escrituras, donde providesTags declara qué territorio cubre una consulta e invalidatesTags declara qué territorio arrasa una mutación. Desarrolla las tres granularidades de una etiqueta —tipo suelto, tipo con identificador y el comodín LIST— explica por qué la forma de función de providesTags recibe resultado, error y argumento, y analiza las dos patologías simétricas del diseño de etiquetas: la invalidación demasiado gruesa que refresca media aplicación y la demasiado fina que deja datos obsoletos en pantalla. Cierra sosteniendo que etiquetar es modelar dependencias causales, no configurar una cache.

⏱ 20 min

La lección anterior dejó una API que sabe pedir datos, cachearlos y deduplicar peticiones, pero que tiene un defecto grave: no sabe cuándo lo que guarda ha dejado de ser cierto. Una cache sin invalidación es un archivo de mentiras con fecha. Las etiquetas resuelven exactamente ese problema, y lo hacen con una idea que merece detenerse a admirar: en lugar de que cada mutación diga qué consultas debe refrescar —un acoplamiento que crece cuadráticamente y que nadie mantiene—, cada consulta declara qué territorio de datos cubre y cada mutación declara qué territorio invalida. Ninguna de las dos conoce a la otra. El emparejamiento lo hace RTK Query en tiempo de ejecución sobre un espacio de nombres compartido, y lo que emerge es un grafo dirigido de dependencias causales que ninguno de los dos extremos escribió, pero que ambos describen.

🎯 Al terminar esta lección sabrás
  • Declarar el vocabulario de tagTypes y entender por qué un espacio cerrado es una garantía de tipos.
  • Escribir providesTags en sus tres granularidades: tipo suelto, tipo con id y el comodín de lista.
  • Escribir invalidatesTags de forma dinámica en función del argumento y del resultado de la mutación.
  • Diagnosticar las dos patologías del etiquetado: la invalidación demasiado gruesa y la demasiado fina.

El espacio de nombres y sus tres granularidades

Una etiqueta en RTK Query no es una cadena arbitraria sino un par de tipo e identificador. El tipo debe pertenecer al array tagTypes declarado en createApi, y esa restricción convierte una clase entera de errores —una etiqueta mal escrita en un lado que nunca casa con la del otro— en un error de compilación en lugar de un dato obsoleto en producción. El identificador es opcional, y de su presencia o ausencia depende toda la aritmética del sistema.

Hay tres formas de etiqueta y conviene fijarlas antes de escribir ninguna. La forma escueta, una cadena como Post, equivale a la etiqueta de tipo Post sin identificador: designa el recurso en abstracto. La forma con identificador, el objeto que empareja type con id, designa una entidad concreta: el post número siete y ningún otro. Y la forma convencional del comodín, que usa un identificador reservado por costumbre —habitualmente LIST—, designa la colección misma: no un post, sino el hecho de que la lista de posts está completa y es la que es. El sistema empareja etiquetas por igualdad exacta del par, sin jerarquías ni herencia: invalidar el tipo Post sin identificador no invalida el post número siete, y viceversa.

import type { Post } from "./tipos";

const etiquetasDeLista = (resultado: Post[] | undefined) =>
  resultado
    ? [
        ...resultado.map((p) => ({ type: "Post" as const, id: p.id })),
        { type: "Post" as const, id: "LIST" },
      ]
    : [{ type: "Post" as const, id: "LIST" }];

Esa función merece leerse dos veces porque contiene el patrón canónico entero. Una consulta que devuelve una colección provee dos cosas a la vez: una etiqueta por cada elemento presente y una etiqueta de lista que representa la composición del conjunto. La distinción es la que permite después invalidar con precisión quirúrgica: editar un post invalida solo su identificador y refresca las vistas que lo muestran; crear o borrar un post invalida LIST porque lo que cambió no es ningún elemento sino la pertenencia al conjunto.

💡
La rama del resultado ausente no es opcional

Fíjate en que la función devuelve la etiqueta de lista incluso cuando el resultado es indefinido. Omitir esa rama es el fallo más común y el más difícil de diagnosticar, porque solo se manifiesta tras un error. Si la primera carga falla, la consulta no provee ninguna etiqueta; si después una mutación invalida LIST, esa consulta rota no está suscrita a nada y no se reintenta, de modo que la vista queda vacía para siempre aunque el servidor ya responda bien. Proveer siempre la etiqueta de colección, haya datos o no, mantiene la consulta dentro del grafo incluso cuando su contenido es un fallo.

providesTags: describir el territorio cubierto

providesTags acepta o bien un array literal o bien una función. La forma de función recibe tres argumentos en orden fijo —resultado, error y argumento del endpoint— y esa firma no es caprichosa: las etiquetas de una consulta rara vez se conocen antes de ver qué devolvió, porque dependen de los identificadores presentes en la respuesta. El tercer argumento permite además etiquetar por la pregunta y no solo por la respuesta, lo cual resulta indispensable en consultas parametrizadas.

listarPosts: builder.query<Post[], void>({
  query: () => "posts",
  providesTags: (resultado) => etiquetasDeLista(resultado),
}),

obtenerPost: builder.query<Post, string>({
  query: (id) => `posts/${id}`,
  providesTags: (_resultado, _error, id) => [{ type: "Post", id }],
}),

postsDeAutor: builder.query<Post[], string>({
  query: (autorId) => `autores/${autorId}/posts`,
  providesTags: (resultado, _error, autorId) => [
    ...(resultado ?? []).map((p) => ({ type: "Post" as const, id: p.id })),
    { type: "Post" as const, id: `AUTOR-${autorId}` },
  ],
}),

Conviene reparar en el segundo endpoint, que etiqueta por el argumento y no por el resultado. Es lo correcto en una consulta de detalle, porque el identificador que la mutación invalidará es el que el usuario pidió, no el que el servidor devolvió, y ambos pueden discrepar cuando la petición falla o cuando la respuesta llega vacía. Etiquetar por la pregunta mantiene a la consulta dentro del grafo aunque su respuesta sea un error, que es exactamente la propiedad que el aviso anterior reclamaba.

El tercer endpoint ilustra una técnica infravalorada: fabricar identificadores sintéticos. La etiqueta que combina el prefijo con el identificador de autor no corresponde a ninguna entidad del backend; es una coordenada inventada por ti para nombrar un subconjunto. Nada impide hacerlo, porque el identificador es una cadena libre y el sistema solo compara igualdad. Esta libertad es la que permite expresar dependencias que el modelo de datos del servidor no expone, y usarla bien es la diferencia entre un grafo de invalidación que refleja tu dominio y uno que solo refleja tus rutas HTTP.

🏷️

Tipo suelto

Grano grueso. Toda consulta del recurso queda ligada. Simple y correcto, pero invalida de más en cuanto la app crece.

🎯

Tipo con identificador

Grano fino. Solo la entidad concreta. Es lo que permite editar un elemento sin recargar la colección entera.

📚

Comodín de colección

La pertenencia al conjunto. Se invalida al crear y al borrar, no al editar, porque lo que cambió fue la composición.

invalidatesTags: describir el territorio arrasado

Del lado de la escritura la firma es simétrica —resultado, error y argumento— pero la semántica se invierte: en vez de declarar qué sostienes, declaras qué derribas. Y aquí conviene una disciplina que se enuncia rápido y se olvida siempre: invalida lo mínimo que garantice coherencia, ni un grano más.

crearPost: builder.mutation<Post, NuevoPost>({
  query: (cuerpo) => ({ url: "posts", method: "POST", body: cuerpo }),
  invalidatesTags: [{ type: "Post", id: "LIST" }],
}),

editarPost: builder.mutation<Post, Post>({
  query: (post) => ({ url: `posts/${post.id}`, method: "PUT", body: post }),
  invalidatesTags: (_resultado, error, post) =>
    error ? [] : [{ type: "Post", id: post.id }],
}),

borrarPost: builder.mutation<void, string>({
  query: (id) => ({ url: `posts/${id}`, method: "DELETE" }),
  invalidatesTags: (_resultado, _error, id) => [
    { type: "Post", id },
    { type: "Post", id: "LIST" },
  ],
}),

Los tres casos agotan la casuística. Crear no toca ninguna entidad existente pero altera la composición: solo LIST. Editar toca una entidad y deja la composición intacta: solo su identificador, y además la rama que devuelve un array vacío ante error evita el refresco inútil de una escritura que nunca ocurrió. Borrar hace ambas cosas: la entidad desaparece y el conjunto cambia. Que este razonamiento se pueda hacer caso por caso, en voz alta y en treinta segundos, es la señal de que el sistema de etiquetas está bien dimensionado.

flowchart LR
Q1[listarPosts] -->|provee Post LIST| T1[etiqueta Post LIST]
Q1 -->|provee Post 7| T2[etiqueta Post 7]
Q2[obtenerPost 7] -->|provee Post 7| T2
M1[crearPost] -->|invalida| T1
M2[editarPost 7] -->|invalida| T2
T1 --> R1[refetch de listarPosts]
T2 --> R2[refetch de las dos consultas]
style T1 fill:#89b4fa,color:#11111b
style T2 fill:#fab387,color:#11111b
style R2 fill:#a6e3a1,color:#11111b

El diagrama hace visible la propiedad esencial: ni crearPost ni editarPost mencionan jamás a listarPosts ni a obtenerPost. El acoplamiento existe, pero pasa por el espacio de etiquetas en lugar de por referencias directas, y eso significa que añadir una consulta nueva que provea Post la incorpora automáticamente al grafo sin tocar ninguna mutación. Es el mismo desacoplamiento que las acciones de Redux consiguieron entre quien despacha y quien reduce, aplicado ahora a la relación entre lectura y escritura.

Las dos patologías simétricas

Hay además un límite del mecanismo que conviene tener presente antes de diagnosticar patologías: la invalidación solo se dispara desde mutaciones de este cliente. Si otro usuario edita el mismo recurso, ninguna etiqueta se activa aquí, porque nadie despachó nada en este navegador. El grafo garantiza coherencia interna entre tus lecturas y tus escrituras, no coherencia con el mundo. Cerrar ese hueco es asunto del refresco por foco, del polling o de un canal de eventos, y es justamente el terreno de la cuarta lección de este nivel.

Un grafo de invalidación puede enfermar de dos maneras opuestas y es útil reconocerlas por su síntoma. La patología gruesa aparece cuando todo se etiqueta con el tipo suelto: cualquier escritura, por menor que sea, invalida todas las consultas del recurso y desencadena una tormenta de peticiones. El síntoma es una pestaña de red que se llena tras cada clic y una UI que parpadea entera al editar un campo. La patología fina es la inversa: identificadores tan específicos que ninguna mutación acierta a invalidar la consulta que debía, y el síntoma es una pantalla que muestra datos correctos junto a datos rancios, típicamente una lista que no refleja lo que el detalle ya sabe.

⚠️
La invalidación cruzada entre recursos se olvida siempre

La patología fina más traicionera no está dentro de un recurso sino entre recursos. Borrar un autor invalida Autor, pero la consulta de posts sigue mostrando entradas de un autor que ya no existe, porque nadie declaró esa dependencia. RTK Query no conoce tu modelo relacional y no puede inferirla. Cada vez que una escritura tenga efectos en cascada del lado del servidor —eliminaciones que arrastran, contadores que se recalculan, permisos que cambian—, tienes que declarar esa cascada tú, invalidando también los tipos afectados. La regla operativa es preguntarse por cada mutación qué otras pantallas mentirían si el usuario navegase a ellas justo después.

Etiquetar no es configurar una cache: es modelar las dependencias causales de tu dominio

Quien lee las etiquetas como una opción de configuración —dos campos que hay que rellenar para que los datos se refresquen— se pierde lo único importante que ocurre aquí. Cuando escribes que listarPosts provee la colección y que crearPost la invalida, no estás ajustando un parámetro: estás afirmando una verdad sobre tu dominio, a saber, que la creación de un post cambia la respuesta a la pregunta de cuáles son todos los posts. Esa afirmación era cierta antes de existir RTK Query y seguirá siéndolo después; lo único que la librería aporta es un lugar donde escribirla. Y ahí está el giro: sin ese lugar, esas verdades causales no desaparecen, simplemente se dispersan. Viven en el recuerdo del programador que sabe que después de guardar hay que recargar la lista, en la llamada manual que alguien añadió tras un bug, en el comentario que advierte de no olvidar refrescar el contador. Están escritas, pero en el peor formato posible: fragmentadas, imperativas y desactualizándose por separado. El grafo de etiquetas hace lo que hacen todas las buenas abstracciones: coge un conocimiento que ya existía disperso en la cabeza del equipo y lo condensa en un artefacto único, explícito y verificable. Por eso el ejercicio de etiquetar bien es incómodo y revelador al mismo tiempo; obliga a responder preguntas que llevabas años respondiendo implícitamente —qué invalida qué, qué depende de qué— y con frecuencia descubre que nadie del equipo tenía la misma respuesta. La cache que se refresca sola es un efecto secundario agradable. El producto real es que tu modelo de dependencias causales ha dejado de ser folclore oral y se ha convertido en código que TypeScript revisa y que un compañero nuevo puede leer.

⚔️ Dibuja y depura tu grafo de invalidación
  1. Enumera en papel las consultas y mutaciones de un módulo real y dibuja las flechas de dependencia causal antes de mirar el código: qué escritura obliga a releer qué.
  2. Traduce el dibujo a providesTags e invalidatesTags usando las tres granularidades, con la etiqueta de colección presente también en la rama sin resultado.
  3. Edita un elemento y comprueba en la pestaña de red que se refrescan las consultas de ese elemento y ninguna más; si se refresca la colección, tu grano es demasiado grueso.
  4. Crea y borra un elemento y verifica lo contrario: que la colección sí se refresca porque cambió la composición del conjunto.
  5. Busca una dependencia entre recursos distintos —una eliminación en cascada, un contador derivado— y comprueba si tu grafo la contempla; casi seguro que no.
  6. Introduce a propósito una etiqueta con identificador que ninguna mutación invalide y observa cuánto tardas en detectar el dato rancio. Ese tiempo es tu coste real de un grafo mal dibujado.