← Regresar a lecciones
  • cache

  • UX

  • Data Fetching

  • React.js

  • performance

  • TanStack Query

  • React Query

Obtención de datos para una interfaz de usuario fluida con React Query

Estrategias de Cache y Staleness: staleTime y cacheTime

En aplicaciones modernas, la experiencia de usuario se degrada rápidamente cuando aparecen spinners constantemente, la interfaz "salta" al cargar datos, o el usuario ve pantallas en blanco mientras espera. React Query (TanStack Query) nos permite crear interfaces fluidas donde los datos se actualizan de forma inteligente en segundo plano, sin interrumpir la interacción del usuario.

Estrategias de Cache y Staleness: staleTime y cacheTime

React Query mantiene dos conceptos fundamentales para controlar cómo y cuándo se refrescan los datos:

  1. staleTime que define cuánto tiempo los datos se consideran "frescos". Durante este período, React Query no realizará una nueva petición automáticamente.
1const { data } = useQuery({ 2 queryKey: ['products'], 3 queryFn: fetchProducts, 4 staleTime: 5 * 60 * 1000, // 5 minutos 5});

Con staleTime: 5 minutos, si el usuario navega a otra página y vuelve dentro de ese tiempo, React Query mostrará los datos cacheados instantáneamente sin hacer una nueva petición.

  1. cacheTime que controla cuánto tiempo permanecen los datos en memoria después de que no hay componentes observándolos. Por defecto es 5 minutos.
1const { data } = useQuery({ 2 queryKey: ['userProfile', userId], 3 queryFn: () => fetchUserProfile(userId), 4 staleTime: 2 * 60 * 1000, // Fresco por 2 minutos 5 cacheTime: 10 * 60 * 1000, // En cache por 10 minutos 6});

Para datos que cambian poco (categorías, configuración), usa staleTime alto (30-60 minutos). Para datos dinámicos (feed de actividad), mantén staleTime bajo pero aprovecha el cache para transiciones rápidas.

keepPreviousData para Transiciones Suaves

Uno de los patrones más poderosos para eliminar el "jank" es mostrar los datos anteriores mientras se cargan los nuevos:

1function ProductList({ category }) { 2 const { data, isFetching } = useQuery({ 3 queryKey: ['products', category], 4 queryFn: () => fetchProductsByCategory(category), 5 keepPreviousData: true, 6 }); 7 8 return ( 9 <div className={isFetching ? 'opacity-50' : ''}> 10 {data?.products.map(product => ( 11 <ProductCard key={product.id} {...product} /> 12 ))} 13 </div> 14 ); 15}

Cuando el usuario cambia de categoría, la lista anterior permanece visible con una ligera opacidad, evitando el estado de loading vacío. La UI nunca "salta" ni muestra spinners. Caso de uso ideal: Paginación, filtros, búsquedas. El usuario mantiene contexto visual mientras los nuevos datos se cargan.

Optimización de Requests

  • De-duplicación Automática: React Query de-duplica automáticamente requests idénticos realizados simultáneamente:
1// Múltiples componentes pueden llamar esto al mismo tiempo 2function UserAvatar({ userId }) { 3 const { data } = useQuery({ 4 queryKey: ['user', userId], 5 queryFn: () => fetchUser(userId), 6 }); 7 8 return <img src={data?.avatar} alt={data?.name} />; 9} 10 11// Si 10 componentes renderizan simultáneamente con el mismo userId, 12// solo se hace UNA petición HTTP

Esto elimina la necesidad de gestionar manualmente qué componente "es responsable" de cargar los datos.

  • Background Refetch Inteligente: React Query refresca datos automáticamente en situaciones clave sin interrumpir al usuario:
1const { data } = useQuery({ 2 queryKey: ['notifications'], 3 queryFn: fetchNotifications, 4 staleTime: 30 * 1000, // Fresco por 30 segundos 5 refetchOnWindowFocus: true, // Refrescar al volver a la pestaña 6 refetchOnReconnect: true, // Refrescar al recuperar conexión 7 refetchInterval: 60 * 1000, // Polling cada minuto 8});

Patrón avanzado: Refetch condicional basado en estado de la aplicación.

1const { data } = useQuery({ 2 queryKey: ['liveScores', matchId], 3 queryFn: () => fetchMatchScores(matchId), 4 refetchInterval: (data) => { 5 // Si el partido terminó, detener polling 6 return data?.status === 'finished' ? false : 5000; 7 }, 8});
  • select: para Transformar y Derivar Datos. La opción select permite transformar datos eficientemente, con memoización automática:
1function ActiveUsers() { 2 const { data: activeCount } = useQuery({ 3 queryKey: ['users'], 4 queryFn: fetchAllUsers, 5 select: (users) => users.filter(u => u.isActive).length, 6 staleTime: 5 * 60 * 1000, 7 }); 8 9 return <Badge>{activeCount} usuarios activos</Badge>; 10}

Ventajas de select:

  • Solo re-renderiza si el resultado transformado cambia (no si cambian otros campos de los datos originales)
  • Múltiples componentes pueden usar la misma query con diferentes selectores
  • Los datos crudos permanecen en cache para otras queries
1// Selector reutilizable 2const selectActiveUsers = (users) => 3 users.filter(u => u.isActive); 4 5// Componente A necesita solo activos 6const { data: activeUsers } = useQuery({ 7 queryKey: ['users'], 8 queryFn: fetchAllUsers, 9 select: selectActiveUsers, 10}); 11 12// Componente B necesita todos 13const { data: allUsers } = useQuery({ 14 queryKey: ['users'], 15 queryFn: fetchAllUsers, 16}); 17 18// Solo 1 petición HTTP, 2 vistas de los datos

Estados Sin Bloquear la UI

  • Skeletons Inteligentes: En lugar de spinners centralizados, usa skeletons que mantienen la estructura visual.
1function ProductCard({ productId }) { 2 const { data, isLoading, isError } = useQuery({ 3 queryKey: ['product', productId], 4 queryFn: () => fetchProduct(productId), 5 }); 6 7 if (isLoading) { 8 return ( 9 <div className="card"> 10 <div className="skeleton-image h-48 bg-gray-200 animate-pulse" /> 11 <div className="skeleton-title h-6 bg-gray-200 animate-pulse mt-4" /> 12 <div className="skeleton-price h-4 bg-gray-200 animate-pulse mt-2 w-1/2" /> 13 </div> 14 ); 15 } 16 17 if (isError) { 18 return <ErrorCard message="No se pudo cargar el producto" />; 19 } 20 21 return ( 22 <div className="card"> 23 <img src={data.image} alt={data.name} /> 24 <h3>{data.name}</h3> 25 <p className="price">${data.price}</p> 26 </div> 27 ); 28}
  • Placeholders con Datos Iniciales: Para mejorar la percepción de velocidad, proporciona datos iniciales mientras se cargan los reales.
1const { data = DEFAULT_CATEGORIES } = useQuery({ 2 queryKey: ['categories'], 3 queryFn: fetchCategories, 4 placeholderData: DEFAULT_CATEGORIES, // Datos hasta que lleguen los reales 5});

Estrategia de Error Progresiva

Maneja errores sin romper toda la interfaz:

1function Dashboard() { 2 const stats = useQuery({ 3 queryKey: ['stats'], 4 queryFn: fetchStats, 5 retry: 3, // Reintentar 3 veces 6 }); 7 8 const activity = useQuery({ 9 queryKey: ['activity'], 10 queryFn: fetchActivity, 11 retry: 1, 12 }); 13 14 return ( 15 <div className="dashboard"> 16 <StatsSection 17 data={stats.data} 18 isError={stats.isError} 19 /> 20 21 <ActivityFeed 22 data={activity.data} 23 isError={activity.isError} 24 /> 25 </div> 26 ); 27} 28 29function StatsSection({ data, isError }) { 30 if (isError) { 31 return ( 32 <div className="stats-error"> 33 <Icon name="alert" /> 34 <p>Estadísticas no disponibles</p> 35 <button onClick={() => queryClient.refetchQueries(['stats'])}> 36 Reintentar 37 </button> 38 </div> 39 ); 40 } 41 42 return data ? <StatsDisplay data={data} /> : <StatsSkeleton />; 43}

Patrón Completo: Lista con Filtros Fluida

Combinando todas las técnicas:

1function SmoothProductList() { 2 const [category, setCategory] = useState('all'); 3 const [search, setSearch] = useState(''); 4 5 const { data, isFetching, isError } = useQuery({ 6 queryKey: ['products', category, search], 7 queryFn: () => fetchProducts({ category, search }), 8 keepPreviousData: true, // Sin saltos al filtrar 9 staleTime: 30 * 1000, // Cache 30s 10 select: (data) => ({ // Derivar conteo 11 products: data.products, 12 count: data.products.length, 13 }), 14 }); 15 16 return ( 17 <div> 18 <SearchBar 19 value={search} 20 onChange={setSearch} 21 isSearching={isFetching} 22 /> 23 24 <CategoryTabs 25 selected={category} 26 onChange={setCategory} 27 /> 28 29 {isError ? ( 30 <ErrorState retry={() => queryClient.refetchQueries(['products'])} /> 31 ) : ( 32 <> 33 <ResultsCount count={data?.count} isUpdating={isFetching} /> 34 35 <div className={isFetching ? 'opacity-70' : ''}> 36 {data?.products.map(product => ( 37 <ProductCard key={product.id} {...product} /> 38 ))} 39 </div> 40 </> 41 )} 42 </div> 43 ); 44}

Conclusión

Una UI smooth con React Query no se trata de eliminar todos los indicadores de carga, sino de mantener al usuario informado sin interrumpir su flujo. Las claves son usar el cache inteligentemente con staleTime, mantener contexto visual con keepPreviousData, transformar datos eficientemente con select, y reemplazar spinners bloqueantes con skeletons y estados progresivos. El resultado es una aplicación que se siente rápida, moderna y confiable.