Mutaciones e invalidación: mantener la cache fresca
Leer es la mitad fácil de una cache; escribir es la difícil. En cuanto envías un POST o un PATCH cambias la verdad remota y todas tus copias locales quedan obsoletas por definición. Esta lección cubre useMutation y su ciclo de vida, invalidateQueries con coincidencia por prefijo de clave como reconciliación declarativa entre escritura y lectura, la elección entre invalidar (pull) y escribir la cache con setQueryData (push), y el preludio de la actualización optimista. El hilo conceptual: una cache no es coherente por guardar bien los datos, sino por saber cuándo dejar de creerlos.
Leer es la mitad fácil de una cache; la difícil es escribir. En cuanto envías un POST o un PATCH, cambias la verdad remota, y en ese mismo instante todas tus copias locales de esa verdad quedan desactualizadas, no por un bug, sino por definición. useMutation es la herramienta para escribir, e invalidateQueries la que reconcilia: le dice a la cache qué lecturas acaban de volverse mentira para que las vuelva a pedir. Esta lección es sobre esa coreografía —escribir, invalidar, revalidar— que mantiene fresca una cache después de cada cambio sin que sincronices nada a mano.
- Ejecutar una escritura con
useMutationy seguir su ciclo de vida. - Entender por qué toda escritura deja obsoletas las lecturas relacionadas.
- Invalidar familias de queries por prefijo de clave con
invalidateQueries. - Elegir entre invalidar (pull) y escribir la cache con
setQueryData(push).
Escribir vuelve mentira lo que leíste
Una lectura y una escritura no son operaciones simétricas sobre la cache. Una lectura rellena una entrada; una escritura tiene un efecto a distancia: cambia la verdad remota y, con ella, invalida todas las entradas que reflejaban esa verdad.
Renombras un usuario con un PATCH y, si no haces nada más, tu lista de usuarios, la ficha de ese usuario y cualquier vista que lo incluyera siguen mostrando el nombre viejo. La cache no se enteró de que el mundo cambió, porque el cambio lo provocaste tú, fuera de su vista.
De ahí que la escritura tenga dos mitades. La primera es mandar el cambio al servidor, y eso es useMutation. La segunda es decirle a la cache qué copias quedaron obsoletas, y eso es la invalidación. Olvidar la segunda mitad es la causa número uno de interfaces que no se actualizan después de guardar.
useMutation: el ciclo de una escritura
A diferencia de useQuery, que se dispara sola, una mutación es imperativa por naturaleza: la ejecutas tú llamando a mutate en respuesta a un clic o un envío.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useRenombrar(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (nombre: string) => api.patch(`/usuario/${id}`, { nombre }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["usuario", id] });
qc.invalidateQueries({ queryKey: ["usuarios"] });
},
});
}
Su ciclo de vida son cuatro momentos con nombre, y colocar la lógica en el momento correcto es la mitad del oficio. La reconciliación de la cache vive casi siempre en onSuccess o en onSettled, porque es cuando sabes que la verdad remota ya cambió.
onMutate
Justo antes de salir. El lugar de la actualización optimista: aplica el cambio esperado y guarda el valor anterior por si hay que revertir.
onSuccess
El servidor aceptó. Momento típico de invalidar las claves afectadas para que las lecturas obsoletas se refresquen solas.
onError
El servidor rechazó. Si fuiste optimista, aquí restauras el valor anterior que guardaste en onMutate.
onSettled
Al final, con éxito o con fallo. Buen sitio para una invalidación de cierre que garantice que cache y servidor coinciden.
Conviene además distinguir mutate, que dispara la escritura y no devuelve promesa, de mutateAsync, que sí la devuelve para encadenar o esperar el resultado. Usa el primero para la mayoría de clics; reserva el segundo para cuando de verdad necesites esperar el desenlace antes de seguir.
invalidateQueries: reconciliar por clave
invalidateQueries hace dos cosas de un golpe: marca como obsoletas las queries que coincidan y, si están activas —montadas ahora mismo—, las vuelve a pedir de inmediato. Lo demás lo hace la próxima vez que se usen.
Lo potente es cómo coincide: por prefijo de clave. Invalidar ["usuarios"] alcanza a ["usuarios"], a ["usuarios", { estado: "activo" }] y a cualquier clave que empiece por ese prefijo. Por eso la jerarquía de claves que diseñaste en la lección anterior no era estética: es lo que te deja tirar una familia entera de lecturas con una sola línea.
flowchart TD M[mutate] -->|patch al servidor| SV[servidor cambia la verdad] SV -->|onSuccess| INV[invalidateQueries por clave] INV -->|marca stale| Q[queries afectadas] Q -->|estan activas| RF[refetch automatico] RF --> UI[vista al dia] style SV fill:#f38ba8,color:#11111b style INV fill:#fab387,color:#11111b style UI fill:#a6e3a1,color:#11111b
Empujar o invalidar: dos formas de refrescar
Invalidar no es la única forma de reconciliar. Hay dos estrategias, y elegir bien ahorra peticiones. La primera es invalidar, un empuje de tipo pull: marcas la clave como obsoleta y dejas que la cache vuelva a pedir el dato al servidor. Es la opción segura por defecto, porque el servidor es siempre la autoridad, pero cuesta una petición extra.
La segunda es escribir la cache directamente con setQueryData, un empuje de tipo push: si la respuesta de la mutación ya trae el objeto actualizado, lo colocas tú mismo en la entrada correspondiente sin volver a preguntar. Más rápido y sin red, pero asumes que tu copia coincide con la del servidor.
De ahí nace la actualización optimista, que el nivel 35 desarrolla: en onMutate escribes el resultado esperado antes de que el servidor conteste, guardas el valor anterior por si acaso, y si la mutación falla lo restauras en onError.
useMutation({
mutationFn: enviarTarea,
onMutate: async (nueva) => {
await qc.cancelQueries({ queryKey: ["tareas"] });
const previo = qc.getQueryData(["tareas"]); // guarda el valor anterior
qc.setQueryData(["tareas"], (t) => [...t, nueva]); // apuesta optimista
return { previo };
},
onError: (_e, _nueva, ctx) => {
qc.setQueryData(["tareas"], ctx.previo); // revierte si el servidor falla
},
onSettled: () => qc.invalidateQueries({ queryKey: ["tareas"] }),
});
La cache tiene, por un instante, un valor que el servidor aún no ratificó: una apuesta que luego se confirma o se deshace. Es el reconocimiento explícito de que tu copia y la verdad remota son dos cosas que hay que reconciliar, no una sola que se pueda escribir sin más.
La invalidación tiene dos errores opuestos y ambos duelen. Invalidar demasiado ancho, tirar ["usuarios"] entero cuando solo cambió uno, revalida de más y machaca la red con peticiones que no hacían falta. Invalidar demasiado estrecho, refrescar la ficha pero olvidar la lista donde ese usuario aparece, deja copias mentirosas en pantalla y produce el clásico lo cambié pero aquí sigue viejo. El acierto es invalidar exactamente la familia que tu escritura ensució: ni una clave más, ni una menos. Y ese acierto se diseña antes, nombrando las claves pensando en qué mutaciones las invalidarán juntas.
La palabra que resume esta lección no es mutación ni invalidación, sino coherencia: el problema de mantener de acuerdo dos cosas que pueden divergir, lo que tu cache cree y lo que el servidor sabe, cada vez que una escritura las separa. Y aquí está la idea que conviene grabar: una cache no es coherente por guardar bien los datos, sino por saber cuándo dejar de creerlos. La parte fácil, la que todo el mundo hace, es la escritura: mandar el PATCH y celebrar el 200. La parte que separa a un profesional es la segunda mitad, la que reconoce que ese PATCH acaba de volver mentira un puñado de lecturas repartidas por la aplicación y que alguien tiene que decirle a la cache cuáles. Cuando esa reconciliación la haces a mano, buscando en el store cada copia del usuario para reescribir su nombre, estás implementando invalidación de cache sin llamarla así, y lo haces peor que la librería, porque tú piensas en pantallas y la cache piensa en claves. invalidateQueries con coincidencia por prefijo es la forma declarativa de esa coherencia: no enumeras las pantallas afectadas, describes la familia de datos que tu escritura ensució y dejas que la cache encuentre y refresque cada copia. Interiorizar esto cambia cómo diseñas: empiezas a nombrar tus claves pensando en qué mutaciones las invalidarán juntas, igual que un buen esquema de base de datos se diseña pensando en las consultas que va a servir. La cache fresca no es la que nunca tiene datos viejos; es la que sabe exactamente cuándo los suyos dejaron de ser ciertos.
- Escribe una
useMutationpara una edición real de tu app y ejecuta la escritura contra el servidor. - Añade la segunda mitad: invalida en
onSuccessla clave del elemento y la de su colección, y observa ambas refrescarse solas. - Provoca a propósito una invalidación demasiado ancha y otra demasiado estrecha, y describe el síntoma de cada error.
- Sustituye una invalidación por un
setQueryDatacuando la respuesta ya traiga el objeto nuevo, y razona qué petición te ahorraste. - Implementa la actualización optimista del ejemplo: qué guardas en
onMutate, qué restauras enonError, y por qué necesitas el valor anterior.