React Query muestra datos viejos al volver: staleTime

Capturas varios registros en una pantalla, abres el detalle de uno, vuelves a la lista y los contadores siguen mostrando los valores de la primera visita. O pagas una cuota, regresas a la pantalla de resumen y esta sigue mostrando el pago como pendiente hasta que recargas la página completa. No hay errores en la consola ni peticiones fallidas. Lo único sospechoso suele ser una configuración global como esta:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 60_000 },
  },
});

Este artículo parte de TanStack Query (React Query) v5 con Next.js App Router. Explica por qué una pantalla que se vuelve a montar puede mostrar datos de la caché sin pedirlos de nuevo y cómo hacer que las pantallas que lo necesitan siempre estén al día.

Por qué pasa

Hay que separar dos ideas: tener datos en caché y volver a pedirlos. Según la referencia de useQuery, los valores por defecto son staleTime: 0 y refetchOnMount: true. Con eso los datos se consideran obsoletos en cuanto llegan, y cada vez que se monta un componente nuevo con la misma query se vuelve a pedir en segundo plano. La guía de valores por defecto añade que lo mismo ocurre al enfocar la ventana y al recuperar la conexión.

Con staleTime: 60_000 el escenario cambia. Durante un minuto los datos cuentan como frescos y refetchOnMount: true solo vuelve a pedir si los datos están obsoletos. Volver desde el detalle monta de nuevo el componente de la lista, la query está en caché y todavía es fresca, así que no se hace ninguna petición. No es una configuración absurda: la guía de SSR de TanStack propone un staleTime por defecto mayor que cero para evitar un refetch inmediato en el cliente, y por eso aparece en muchos proyectos con Next.js. El problema es aplicarlo a todas las pantallas, incluidas las que deben reflejar cambios recientes.

Dos factores más suelen agravarlo:

  • Estado local sembrado una sola vez. Si copias los datos con useState(data.total), la documentación de React indica que el valor inicial se ignora después del primer render. Aunque la query se actualice, la copia local no.
  • Layouts que no se vuelven a montar. En el App Router, las transiciones del lado del cliente conservan los layouts compartidos, según la documentación de Next.js. Si un layout consulta datos mientras hay una operación a medias (por ejemplo, una verificación pendiente), esa respuesta intermedia queda en la caché y las pantallas que usan la misma query dentro del staleTime la reutilizan.

Solución

  1. Marca como siempre frescas las pantallas que lo necesitan. Para una query concreta, usa refetchOnMount: 'always':

    const { data } = useQuery({
      queryKey: ['pedidos'],
      queryFn: fetchPedidos,
      refetchOnMount: 'always',
    });

    La referencia indica que con 'always' la query se vuelve a pedir en cada montaje (salvo con staleTime: 'static'). Mientras llega la respuesta se sigue mostrando lo que hay en caché. Otra opción es poner staleTime: 0 en esa query, que sustituye al valor global; en ese caso también se vuelve a pedir al enfocar la ventana y al reconectar.

  2. Invalida las queries después de cada mutación exitosa. Es la medida más importante, porque no depende del montaje:

    const queryClient = useQueryClient();
    
    const pagarCuota = useMutation({
      mutationFn: enviarPago,
      onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plan'] }),
    });

    Según la guía de invalidación, invalidateQueries marca las queries como obsoletas (ignorando el staleTime) y vuelve a pedir las que están en uso; las demás se piden la próxima vez que se necesiten. La clave funciona por prefijo: ['plan'] alcanza también a ['plan', { mes: 3 }], y con exact: true se desactiva ese comportamiento. Por eso conviene diseñar las claves de lo general a lo específico. Si la mutación afecta a varias pantallas, invalida cada grupo:

    onSuccess: async () => {
      await Promise.all([
        queryClient.invalidateQueries({ queryKey: ['plan'] }),
        queryClient.invalidateQueries({ queryKey: ['resumen'] }),
      ]);
    },

    Si un callback de la mutación devuelve una promesa, la documentación de mutaciones indica que se espera a que termine antes de llamar al siguiente callback (por ejemplo, onSettled). Devolver la invalidación, como en los ejemplos, aprovecha ese comportamiento.

  3. No copies los datos del servidor en estado local. Lo más simple es derivar el valor en cada render:

    const total = data?.total ?? 0;

    La documentación de React recomienda calcular durante el render lo que se pueda derivar y, para reiniciar un componente completo, cambiar su key. Si necesitas un borrador editable que deba reiniciarse cuando cambie el valor del servidor, sincronízalo con un efecto que dependa de un valor primitivo, no de todo el objeto:

    const nombreServidor = data?.nombre;
    const [nombre, setNombre] = useState('');
    
    useEffect(() => {
      if (nombreServidor !== undefined) setNombre(nombreServidor);
    }, [nombreServidor]);

    Ten en cuenta que este efecto sobrescribe lo que el usuario esté escribiendo cuando el valor del servidor cambie; úsalo solo si ese comportamiento es el esperado.

  4. Revisa las queries que lee un layout. Un layout no se vuelve a montar al navegar, así que refetchOnMount no sirve ahí. Invalida su query cuando cambie el estado que representa, o dale un staleTime corto propio en lugar del global.

Cómo verificar que quedó resuelto

  1. Abre las herramientas de desarrollo del navegador, pestaña Red, y filtra por Fetch/XHR. Entra a la lista, abre un detalle y vuelve. Debe aparecer una petición nueva a la lista; antes del cambio no aparecía ninguna.
  2. Captura dos registros, entra al detalle de uno, vuelve y comprueba que los contadores reflejan los dos.
  3. Ejecuta la mutación (por ejemplo, el pago) y vuelve a la pantalla de resumen. Debe mostrar el nuevo estado sin recargar la página.
  4. Añade una prueba que desmonte y vuelva a montar la lista con el mismo QueryClient y compruebe que la función de consulta se llama dos veces:
const queryFn = vi.fn().mockResolvedValue([{ id: 1 }]);
const queryClient = new QueryClient();
const wrapper = ({ children }) => (
  <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

const { unmount } = render(<Lista queryFn={queryFn} />, { wrapper });
await screen.findByText('1 registro');
unmount();

render(<Lista queryFn={queryFn} />, { wrapper });
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2));

Es importante reutilizar el mismo queryClient en ambos montajes; con uno nuevo la prueba no reproduce el problema, porque la caché empezaría vacía.

Variantes y errores relacionados

Sigue sin actualizar aunque uses refetchOnMount: 'always'

Casi siempre significa que el componente no se está volviendo a montar (un layout, o una pantalla que solo se oculta con CSS). Usa la invalidación del paso 2 o revisa dónde vive la query.

staleTime: 'static'

La referencia indica que con ese valor los datos nunca se consideran obsoletos y que 'always' tampoco fuerza el refetch. Resérvalo para datos que realmente no cambian.

Al volver a enfocar la ventana tampoco se actualiza

refetchOnWindowFocus también respeta el staleTime: con true solo vuelve a pedir si los datos están obsoletos, y admite el valor 'always' igual que refetchOnMount.

Datos que no vienen de React Query

Si la pantalla obtiene los datos en un Server Component, este artículo no aplica: ahí la caché y la revalidación las controla Next.js.

Resumen

  • Con un staleTime global alto, una pantalla que se vuelve a montar usa la caché sin pedir los datos de nuevo mientras sean frescos.
  • Para pantallas que deben estar al día, usa refetchOnMount: 'always' o un staleTime bajo en esa query.
  • Invalida con queryClient.invalidateQueries({ queryKey: [...] }) tras cada mutación exitosa; la clave se compara por prefijo.
  • No copies datos del servidor en useState: derívalos, usa key o, como último recurso, un efecto sobre valores primitivos.
  • Verifica con la pestaña Red y con una prueba que desmonte y monte de nuevo el componente.

Referencias

Comentarios