wandres.dev
RTK QUERY A FONDO · cache y tags

Polling, prefetch y actualizaciones optimistas

Las tres técnicas que separan una cache correcta de una interfaz que se siente viva, tratadas por lo que tienen en común: todas manipulan el eje del tiempo. El polling adelanta el refresco a un reloj y esta lección analiza su coste real, la diferencia entre pollingInterval y skipPollingIfUnfocused, y por qué casi siempre es la respuesta perezosa a un problema de eventos. El prefetch mueve la petición antes de la intención del usuario, con usePrefetch y el prefetch imperativo desde el middleware, y con la discusión honesta sobre cuándo el ancho de banda especulativo se paga. Las actualizaciones optimistas invierten el orden causal completo: onQueryStarted, updateQueryData y el parche reversible, frente a la alternativa pesimista de parchear tras la confirmación. Cierra distinguiendo el optimismo legítimo del que solo aplaza la mentira.

⏱ 20 min

Con endpoints declarados, un grafo de invalidación bien dibujado y una frontera de transformación limpia, tienes una cache correcta. Correcta no es lo mismo que viva. Una cache correcta refresca cuando algo la invalida, y eso significa que entre el instante en que un dato deja de ser cierto en el servidor y el instante en que tu usuario lo descubre puede pasar una eternidad, porque la invalidación solo la desencadenan las escrituras que hace ese mismo cliente. Las tres técnicas de esta lección atacan ese hueco desde ángulos distintos, y todas comparten la misma naturaleza: son manipulaciones del eje temporal. El polling adelanta el refresco a un reloj. El prefetch adelanta la petición a la intención. Las actualizaciones optimistas adelantan el resultado a la confirmación. Ganar tiempo siempre se paga con algo, y la competencia real no es saber activarlas sino saber qué estás pagando en cada caso.

🎯 Al terminar esta lección sabrás
  • Configurar pollingInterval y sus modificadores, y evaluar honestamente su coste frente a alternativas por evento.
  • Adelantar peticiones con usePrefetch y con initiate desde código imperativo.
  • Implementar actualizaciones optimistas con onQueryStarted, updateQueryData y el parche reversible.
  • Distinguir el patrón optimista del pesimista y elegir según la probabilidad y el coste del fallo.

Polling: refrescar por reloj

La opción pollingInterval en un hook de consulta reejecuta la petición cada tantos milisegundos mientras haya al menos un componente suscrito. Su valor de cero, el predeterminado, desactiva el mecanismo. Lo importante es lo que sucede cuando varios componentes se suscriben a la misma entrada con intervalos distintos: RTK Query no suma ni promedia, adopta el menor de todos, porque el contrato es que ningún suscriptor reciba datos más viejos de lo que pidió.

function PanelDeMetricas({ activo }: { activo: boolean }) {
  const { data, isFetching } = useObtenerMetricasQuery(undefined, {
    pollingInterval: activo ? 15_000 : 0,
    skipPollingIfUnfocused: true,
    refetchOnReconnect: true,
  });

  return <section aria-busy={isFetching}>{data?.enCola ?? 0} trabajos en cola</section>;
}

skipPollingIfUnfocused merece activarse casi siempre y es reciente en la librería. Sin ella, una pestaña olvidada en segundo plano golpea tu backend cada quince segundos durante horas, multiplicada por cada usuario que dejó la aplicación abierta; con ella, el reloj se detiene al perder el foco y se reanuda —con un refresco inmediato— al recuperarlo. Junto con refetchOnFocus y refetchOnReconnect, que dependen de haber llamado a setupListeners, forman el paquete mínimo de higiene de cualquier consulta con polling.

⚠️
El polling suele ser la respuesta perezosa a un problema de eventos

Antes de fijar un intervalo, pregúntate qué estás modelando. Si el dato cambia porque ocurre un suceso en el servidor —un trabajo termina, llega un mensaje, otro usuario edita—, lo que necesitas es un canal de eventos, y el onCacheEntryAdded de RTK Query está diseñado exactamente para eso: abre un websocket o un flujo de eventos servidor cuando nace la entrada de cache, parchea los datos con updateCachedData a medida que llegan los mensajes y lo cierra al expirar la entrada. El polling solo es la elección correcta cuando el dato cambia de forma continua e impredecible y no existe canal de notificación, o cuando la latencia tolerable es tan alta que abrir una conexión persistente no compensa. Elegirlo por defecto convierte un problema de arquitectura en una factura de infraestructura.

Prefetch: adelantarse a la intención

Prefetchear es pedir un dato antes de que nadie lo haya pedido, apostando a que se pedirá. usePrefetch devuelve una función que recibe el argumento del endpoint y una opción de vigencia, y la señal de disparo natural es la que precede a la navegación: el puntero sobre un enlace, el foco sobre una fila, la aparición del elemento en el viewport.

function FilaDePost({ post }: { post: Post }) {
  const prefetch = usePrefetch("obtenerPost");
  const adelantar = () => prefetch(post.id, { ifOlderThan: 60 });

  return (
    <li onMouseEnter={adelantar} onFocus={adelantar}>
      <a href={`/posts/${post.id}`}>{post.titulo}</a>
    </li>
  );
}

La opción ifOlderThan es la que evita que el prefetch se convierta en una fuga: pide solo si la entrada no existe o si su última obtención es más antigua que los segundos indicados. Su alternativa, force, ignora la cache por completo y rara vez es lo que quieres desde un evento de puntero, porque un usuario recorriendo una lista con el ratón dispararía decenas de peticiones idénticas.

Fuera de React el mecanismo es el mismo pero explícito: despachar api.util.prefetch o directamente el initiate del endpoint. Esto es lo que permite prefetchear desde un middleware de escucha —al despachar la acción de abrir un modal, pedir ya sus datos— o desde un cargador de ruta.

import { listenerMiddleware } from "./listener";
import { api } from "./api";

listenerMiddleware.startListening({
  actionCreator: abrirDetalle,
  effect: async (accion, { dispatch }) => {
    dispatch(api.util.prefetch("obtenerPost", accion.payload.id, { ifOlderThan: 30 }));
  },
});
⏱️

Polling

Adelanta el refresco a un reloj. Paga con peticiones que casi siempre devuelven lo mismo. Suspéndelo sin foco.

🚀

Prefetch

Adelanta la petición a la intención. Paga con ancho de banda especulativo. Acótalo con vigencia.

Optimista

Adelanta el resultado a la confirmación. Paga con el riesgo de tener que retractarse ante el usuario.

Actualizaciones optimistas: invertir el orden causal

De las tres, la optimista es la única que altera la causalidad y no solo el calendario. Escribes en la cache el resultado que aún no ha ocurrido, dejas que la UI lo muestre de inmediato y te comprometes a deshacerlo si el servidor te contradice. onQueryStarted es el gancho donde vive esa promesa: se ejecuta al iniciarse la mutación, recibe el argumento y un conjunto de utilidades entre las que están dispatch y queryFulfilled, la promesa que resuelve o rechaza según el desenlace real.

editarPost: builder.mutation<Post, Post>({
  query: (post) => ({ url: `posts/${post.id}`, method: "PUT", body: post }),
  async onQueryStarted(post, { dispatch, queryFulfilled }) {
    const parcheLista = dispatch(
      api.util.updateQueryData("listarPosts", undefined, (borrador) => {
        const previo = borrador.entities[post.id];
        if (previo) previo.titulo = post.titulo;
      }),
    );
    const parcheDetalle = dispatch(
      api.util.updateQueryData("obtenerPost", post.id, (borrador) => {
        borrador.titulo = post.titulo;
      }),
    );
    try {
      await queryFulfilled;
    } catch {
      parcheLista.undo();
      parcheDetalle.undo();
    }
  },
  invalidatesTags: (_r, error, post) =>
    error ? [] : [{ type: "Post", id: post.id }],
}),

Dos detalles concentran toda la dificultad. El primero es que el parche debe aplicarse a todas las entradas afectadas, no solo a la que el usuario está mirando: si editas desde el detalle y olvidas la lista, al volver atrás verás el título antiguo hasta que algo invalide, y el usuario concluirá que su cambio se perdió. El segundo es que undo no es un setState inverso sino una reversión estructural: RTK Query guarda los parches inmer producidos por tu función y los aplica en sentido contrario, de modo que revertir es correcto incluso si entretanto llegó otra escritura sobre la misma entrada.

sequenceDiagram
participant U as usuario
participant C as cache
participant S as servidor
U->>C: dispara editarPost
C->>C: updateQueryData aplica el parche
C-->>U: la UI ya muestra el titulo nuevo
C->>S: peticion PUT en vuelo
alt el servidor confirma
  S-->>C: respuesta correcta
  C->>C: invalidatesTags refresca la verdad
else el servidor rechaza
  S-->>C: error
  C->>C: parche.undo revierte
  C-->>U: la UI vuelve al estado anterior
end

Optimismo legítimo y optimismo que aplaza la mentira

No toda escritura merece ser optimista, y el criterio no es la moda sino un producto de dos factores: la probabilidad de que el servidor rechace y el coste de que el usuario tenga que ver una retractación. Marcar un mensaje como leído casi nunca falla y su retractación es invisible: optimismo evidente. Confirmar un pago falla con frecuencia y su retractación es devastadora: pesimismo obligado. Entre ambos extremos vive el patrón intermedio que RTK Query también soporta, la actualización pesimista, donde se espera a queryFulfilled y se parchea con la respuesta real, ahorrando el refetch sin arriesgar ninguna mentira.

async onQueryStarted(_arg, { dispatch, queryFulfilled }) {
  const { data: creado } = await queryFulfilled;
  dispatch(
    api.util.updateQueryData("listarPosts", undefined, (borrador) => {
      adaptador.addOne(borrador, creado);
    }),
  );
},

Este patrón resuelve además el problema del identificador temporal. Una creación optimista tiene que inventar un id que el servidor aún no ha asignado, y ese identificador falso contamina claves de React, rutas y etiquetas hasta que llega el real. Parchear tras la confirmación evita esa ficción por completo a cambio de la latencia de una ida y vuelta, que en una creación —donde el usuario ya espera un formulario que se cierra— es casi siempre aceptable.

El optimismo no acelera nada: traslada la incertidumbre del sistema al usuario

Conviene decirlo sin adornos porque el nombre de la técnica invita al autoengaño. Una actualización optimista no hace que la escritura sea más rápida; la petición tarda exactamente lo mismo, el servidor decide exactamente cuando iba a decidir, y ni un solo milisegundo de latencia real desaparece. Lo único que ocurre es que has decidido dejar de mostrar la incertidumbre. Antes, el usuario veía un spinner y sabía que el sistema no sabía; ahora ve un hecho consumado y cree que el sistema sí sabe. Has cambiado una espera honesta por una afirmación probable, y esa transacción tiene un beneficiario claro —la sensación de fluidez— y un acreedor que casi nunca se nombra: la confianza del usuario, que has empeñado en tu apuesta. Mientras aciertes, nadie repara en la deuda. El día que falles, la interfaz tiene que retractarse de algo que ya presentó como cierto, y una retractación cuesta muchísimo más que una espera, porque no solo devuelve al usuario donde estaba sino que le enseña que lo que ve puede no ser verdad. A partir de ahí desconfiará también de las veces que acertaste. De ahí que la decisión correcta nunca sea la técnica sino la aritmética: cuánto vale la fluidez ganada multiplicada por la probabilidad de acierto, frente a cuánto cuesta la retractación multiplicada por la de fallo. Cuando esa cuenta sale a favor —escrituras casi infalibles cuyo desmentido es inocuo— el optimismo es un regalo. Cuando sale en contra, mostrar honestamente que el sistema está pensando no es una interfaz peor: es la única que no promete lo que todavía no puede cumplir. Y esa es la razón por la que las tres técnicas de esta lección no se activan por defecto en ninguna librería seria. Ninguna es gratis, todas mueven un coste de sitio, y decidir dónde ponerlo es tuyo, no de la herramienta.

⚔️ Mueve el tiempo y mide lo que pagas
  1. Añade pollingInterval a una consulta y observa en la pestaña de red cuántas respuestas son idénticas a la anterior; ese porcentaje es tu desperdicio.
  2. Activa skipPollingIfUnfocused y comprueba en una pestaña de fondo que el reloj se detiene y se reanuda con un refresco al volver.
  3. Sustituye un polling por un onCacheEntryAdded con un canal de eventos y compara el número de peticiones y la latencia percibida.
  4. Añade prefetch al pasar el puntero sobre una lista con ifOlderThan y luego quítalo para medir la diferencia de tiempo hasta el primer píxel del detalle.
  5. Implementa una edición optimista que parchee las dos entradas afectadas y fuerza un fallo del servidor para verificar que ambas revierten.
  6. Reescribe una creación optimista al patrón pesimista con parcheo tras la confirmación y argumenta cuál de las dos versiones merece tu dominio.