Skip to content

Cache y Invalidation

Cache y Invalidation: El Corazón de TanStack Query

Section titled “Cache y Invalidation: El Corazón de TanStack Query”

El sistema de cache de TanStack Query es su característica más poderosa. Entender cómo funciona y cómo controlarlo te permitirá crear aplicaciones increíblemente rápidas y eficientes.

El cache de TanStack Query funciona como un mapa donde cada query se identifica por su queryKey:

// Cache interno (conceptual)
{
"['posts']": {
data: [...],
dataUpdatedAt: 1640995200000,
isStale: false,
isInvalidated: false
},
"['user', 123]": {
data: { id: 123, name: 'Juan' },
dataUpdatedAt: 1640995300000,
isStale: true,
isInvalidated: false
}
}

Cada entry del cache tiene varios estados importantes:

function CacheStatesDemo() {
const queryClient = useQueryClient();
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000 // 5 minutos
});
const inspectCacheState = () => {
const queryState = queryClient.getQueryState(['posts']);
console.log({
data: queryState.data,
dataUpdatedAt: new Date(queryState.dataUpdatedAt),
isStale: queryState.isStale,
isInvalidated: queryState.isInvalidated,
isLoading: queryState.isLoading,
status: queryState.status, // 'idle' | 'loading' | 'error' | 'success'
fetchStatus: queryState.fetchStatus // 'idle' | 'fetching' | 'paused'
});
};
return (
<div>
<button onClick={inspectCacheState}>
🔍 Inspeccionar Estado del Cache
</button>
{/* Render posts */}
</div>
);
}

Dos conceptos fundamentales que a menudo se confunden:

Tiempo que los datos se consideran “frescos”:

const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 5 * 60 * 1000, // 5 minutos
});
// Comportamiento:
// - 0-5 min: Datos "fresh" - no se refetch automáticamente
// - 5+ min: Datos "stale" - se refetch en background focus, mount, etc.

Tiempo que los datos permanecen en memoria después de que no se usan:

const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
cacheTime: 10 * 60 * 1000, // 10 minutos
});
// Comportamiento:
// - Mientras el componente está montado: datos en cache
// - Después de unmount: esperar 10 minutos antes de limpiar cache
// - Si remonta antes de 10 min: usa datos del cache inmediatamente
function StaleVsCacheDemo() {
const [showPosts, setShowPosts] = useState(true);
return (
<div>
<button onClick={() => setShowPosts(!showPosts)}>
{showPosts ? 'Ocultar' : 'Mostrar'} Posts
</button>
{showPosts && <PostsComponent />}
</div>
);
}
function PostsComponent() {
const { data, isStale, isFetching } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 30 * 1000, // 30 segundos fresh
cacheTime: 5 * 60 * 1000, // 5 minutos en cache
});
useEffect(() => {
console.log('Posts component mounted');
return () => console.log('Posts component unmounted');
}, []);
return (
<div>
<div>
Stale: {isStale ? '🔄 Obsoleto' : '✅ Fresco'} |
Fetching: {isFetching ? '📡 Cargando' : '💤 Idle'}
</div>
{/* Render posts */}
</div>
);
}
// Flujo de comportamiento:
// 1. Mount: fetch initial, fresh por 30s
// 2. 30s+: se marca stale, refetch en background en focus/mount
// 3. Unmount: datos permanecen en cache por 5 minutos
// 4. Remount <5min: datos del cache instantáneamente, pero refetch si stale
// 5. Remount >5min: cache vacío, fetch desde cero
function ManualInvalidation() {
const queryClient = useQueryClient();
const invalidateAllPosts = () => {
// Invalidar todas las queries de posts
queryClient.invalidateQueries({ queryKey: ['posts'] });
};
const invalidateSpecificPost = (postId) => {
// Invalidar post específico
queryClient.invalidateQueries({ queryKey: ['post', postId] });
};
const invalidateUserData = (userId) => {
// Invalidar múltiples queries relacionadas con un usuario
queryClient.invalidateQueries({ queryKey: ['user', userId] });
queryClient.invalidateQueries({ queryKey: ['posts', 'user', userId] });
queryClient.invalidateQueries({ queryKey: ['comments', 'user', userId] });
};
const invalidateStaleOnly = () => {
// Solo invalidar queries que ya están stale
queryClient.invalidateQueries({
stale: true,
refetchType: 'active' // solo las activas (componentes montados)
});
};
return (
<div>
<button onClick={invalidateAllPosts}>
🔄 Invalidar Todos los Posts
</button>
<button onClick={() => invalidateSpecificPost(1)}>
🔄 Invalidar Post 1
</button>
<button onClick={() => invalidateUserData(123)}>
🔄 Invalidar Datos Usuario 123
</button>
<button onClick={invalidateStaleOnly}>
🔄 Invalidar Solo Stale
</button>
</div>
);
}

2. Invalidación Automática tras Mutaciones

Section titled “2. Invalidación Automática tras Mutaciones”
function AutoInvalidationAfterMutation() {
const queryClient = useQueryClient();
// Crear post
const createPostMutation = useMutation({
mutationFn: createPost,
onSuccess: (newPost) => {
// Estrategia 1: Invalidar lista de posts
queryClient.invalidateQueries({ queryKey: ['posts'] });
// Estrategia 2: Agregar al cache directamente (más rápido)
queryClient.setQueryData(['posts'], (oldPosts) => [newPost, ...oldPosts]);
// Estrategia 3: Invalidar queries relacionadas con el usuario
queryClient.invalidateQueries({
queryKey: ['posts', 'user', newPost.userId]
});
}
});
// Actualizar post
const updatePostMutation = useMutation({
mutationFn: updatePost,
onSuccess: (updatedPost, variables) => {
// Actualizar post específico
queryClient.setQueryData(['post', variables.id], updatedPost);
// Invalidar listas que puedan contener este post
queryClient.invalidateQueries({ queryKey: ['posts'] });
queryClient.invalidateQueries({
queryKey: ['posts', 'user', updatedPost.userId]
});
}
});
// Eliminar post
const deletePostMutation = useMutation({
mutationFn: deletePost,
onSuccess: (_, deletedId) => {
// Remover query específica
queryClient.removeQueries({ queryKey: ['post', deletedId] });
// Actualizar listas removiendo el post
queryClient.setQueriesData(
{ queryKey: ['posts'] },
(oldPosts) => oldPosts?.filter(post => post.id !== deletedId)
);
}
});
return (
<div>
<button onClick={() => createPostMutation.mutate({ title: 'Nuevo Post' })}>
➕ Crear Post
</button>
<button onClick={() => updatePostMutation.mutate({ id: 1, title: 'Actualizado' })}>
✏️ Actualizar Post 1
</button>
<button onClick={() => deletePostMutation.mutate(1)}>
🗑️ Eliminar Post 1
</button>
</div>
);
}
function TimeBasedInvalidation() {
const queryClient = useQueryClient();
useEffect(() => {
// Invalidar datos cada 5 minutos
const interval = setInterval(() => {
queryClient.invalidateQueries({
queryKey: ['realtime-data'],
refetchType: 'active'
});
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [queryClient]);
// Invalidar cuando la ventana regaine focus después de mucho tiempo
useEffect(() => {
let lastFocusTime = Date.now();
const handleFocus = () => {
const timeSinceFocus = Date.now() - lastFocusTime;
const fiveMinutes = 5 * 60 * 1000;
if (timeSinceFocus > fiveMinutes) {
// Han pasado más de 5 minutos, invalidar datos críticos
queryClient.invalidateQueries({
queryKey: ['critical-data'],
refetchType: 'active'
});
}
lastFocusTime = Date.now();
};
const handleBlur = () => {
lastFocusTime = Date.now();
};
window.addEventListener('focus', handleFocus);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('focus', handleFocus);
window.removeEventListener('blur', handleBlur);
};
}, [queryClient]);
return <div>Invalidación automática configurada</div>;
}
function CachePropagation() {
const queryClient = useQueryClient();
const updateUserMutation = useMutation({
mutationFn: updateUser,
onSuccess: (updatedUser) => {
// 1. Actualizar usuario específico
queryClient.setQueryData(['user', updatedUser.id], updatedUser);
// 2. Actualizar usuario en lista de usuarios
queryClient.setQueryData(['users'], (oldUsers) =>
oldUsers?.map(user =>
user.id === updatedUser.id ? updatedUser : user
)
);
// 3. Actualizar posts del usuario si el nombre cambió
if (updatedUser.name) {
queryClient.setQueriesData(
{ queryKey: ['posts', 'user', updatedUser.id] },
(oldPosts) =>
oldPosts?.map(post => ({
...post,
authorName: updatedUser.name
}))
);
}
// 4. Invalidar queries que dependen de este usuario
queryClient.invalidateQueries({
predicate: (query) => {
// Invalidar cualquier query que incluya este userId
return query.queryKey.includes(updatedUser.id);
}
});
}
});
return (
<button onClick={() => updateUserMutation.mutate({ id: 1, name: 'Nuevo Nombre' })}>
Actualizar Usuario
</button>
);
}
// Utility para normalizar datos relacionados
function normalizePostsWithUsers(posts, users) {
const userMap = new Map(users.map(user => [user.id, user]));
return posts.map(post => ({
...post,
author: userMap.get(post.userId) || null
}));
}
function NormalizedDataExample() {
const queryClient = useQueryClient();
// Fetch posts y users por separado
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
// Combinar datos usando select
const { data: normalizedPosts } = useQuery({
queryKey: ['posts', 'normalized'],
queryFn: () => Promise.resolve(null), // No fetch real
select: () => {
const cachedPosts = queryClient.getQueryData(['posts']);
const cachedUsers = queryClient.getQueryData(['users']);
if (cachedPosts && cachedUsers) {
return normalizePostsWithUsers(cachedPosts, cachedUsers);
}
return [];
},
enabled: !!(posts && users), // Solo cuando ambos están disponibles
});
return (
<div>
{normalizedPosts?.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>Por: {post.author?.name || 'Autor desconocido'}</p>
</div>
))}
</div>
);
}

Pre-poblar el cache con datos conocidos:

function CacheWarming() {
const queryClient = useQueryClient();
const warmUpCache = async () => {
// Warm up datos críticos
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['user', 'current'],
queryFn: fetchCurrentUser,
staleTime: 10 * 60 * 1000
}),
queryClient.prefetchQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 2 * 60 * 1000
}),
queryClient.prefetchQuery({
queryKey: ['dashboard-stats'],
queryFn: fetchDashboardStats,
staleTime: 5 * 60 * 1000
})
]);
};
// Warm up en mount de la app
useEffect(() => {
warmUpCache();
}, []);
// Warm up basado en interacciones
const handleUserHover = (userId) => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000
});
};
return (
<div>
<button onClick={warmUpCache}>
🔥 Warm Up Cache
</button>
</div>
);
}
utils/cachePatterns.js
export const cachePatterns = {
// Invalidar todos los datos de un usuario
invalidateUserData: (queryClient, userId) => {
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key.includes('user') && key.includes(userId) ||
key.includes('posts') && key.includes(userId) ||
key.includes('comments') && key.includes(userId)
);
}
});
},
// Actualizar usuario en todas las ubicaciones
updateUserEverywhere: (queryClient, updatedUser) => {
// Usuario individual
queryClient.setQueryData(['user', updatedUser.id], updatedUser);
// Lista de usuarios
queryClient.setQueryData(['users'], (oldUsers) =>
oldUsers?.map(user =>
user.id === updatedUser.id ? updatedUser : user
)
);
// Posts del usuario
queryClient.setQueriesData(
{ queryKey: ['posts'] },
(oldPosts) =>
oldPosts?.map(post =>
post.userId === updatedUser.id
? { ...post, authorName: updatedUser.name }
: post
)
);
},
// Limpiar cache obsoleto
cleanupStaleCache: (queryClient) => {
queryClient.removeQueries({
type: 'inactive',
stale: true,
predicate: (query) => {
const age = Date.now() - query.state.dataUpdatedAt;
const oneHour = 60 * 60 * 1000;
return age > oneHour; // Remover datos inactivos de más de 1 hora
}
});
}
};
// Uso en componentes
function UserManagement() {
const queryClient = useQueryClient();
const updateUserMutation = useMutation({
mutationFn: updateUser,
onSuccess: (updatedUser) => {
cachePatterns.updateUserEverywhere(queryClient, updatedUser);
}
});
const cleanupCache = () => {
cachePatterns.cleanupStaleCache(queryClient);
};
return (
<div>
<button onClick={cleanupCache}>
🧹 Limpiar Cache
</button>
</div>
);
}
function CacheMonitor() {
const queryClient = useQueryClient();
const [cacheStats, setCacheStats] = useState({});
const updateStats = useCallback(() => {
const cache = queryClient.getQueryCache();
const queries = cache.getAll();
const stats = {
total: queries.length,
active: queries.filter(q => q.isActive()).length,
stale: queries.filter(q => q.isStale()).length,
loading: queries.filter(q => q.state.isFetching).length,
errors: queries.filter(q => q.state.isError).length,
memoryUsage: estimateCacheSize(queries)
};
setCacheStats(stats);
}, [queryClient]);
// Función para estimar el tamaño del cache
const estimateCacheSize = (queries) => {
let totalSize = 0;
queries.forEach(query => {
if (query.state.data) {
totalSize += JSON.stringify(query.state.data).length;
}
});
return totalSize;
};
useEffect(() => {
updateStats();
const interval = setInterval(updateStats, 5000); // Actualizar cada 5 segundos
return () => clearInterval(interval);
}, [updateStats]);
return (
<div style={{
position: 'fixed',
top: '10px',
right: '10px',
background: 'white',
padding: '10px',
border: '1px solid #ccc',
borderRadius: '8px',
fontSize: '12px',
zIndex: 9999
}}>
<h4>📊 Cache Stats</h4>
<div>Total: {cacheStats.total}</div>
<div>Active: {cacheStats.active}</div>
<div>Stale: {cacheStats.stale}</div>
<div>Loading: {cacheStats.loading}</div>
<div>Errors: {cacheStats.errors}</div>
<div>Size: ~{Math.round(cacheStats.memoryUsage / 1024)}KB</div>
<button
onClick={updateStats}
style={{ marginTop: '5px', fontSize: '10px' }}
>
🔄 Refresh
</button>
</div>
);
}
  1. Configura staleTime apropiadamente: Basado en qué tan frecuentemente cambian tus datos
  2. Usa invalidación específica: En lugar de invalidar todo
  3. Implementa cache warming: Para datos críticos
  4. Normaliza datos relacionados: Para evitar inconsistencias
  5. Monitorea el tamaño del cache: Para prevenir uso excesivo de memoria
  6. Usa predicados para invalidación: Para queries complejas
  7. Implementa cleanup periódico: Para cache inactivo
  8. Considera la propagación de cambios: Actualiza datos relacionados
  9. Usa refetchType apropiado: ‘active’, ‘inactive’, o ‘all’
  10. Debug con DevTools: Para entender el comportamiento del cache

Ahora que dominas el cache, exploremos las actualizaciones optimistas para mejorar la experiencia del usuario.

Próximo paso: Optimistic Updates