cache
UX
Data Fetching
React.js
performance
TanStack Query
React Query
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.
staleTime y cacheTimeReact Query mantiene dos conceptos fundamentales para controlar cómo y cuándo se refrescan los datos:
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});
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 SuavesUno 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.
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.
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:
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
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}
1const { data = DEFAULT_CATEGORIES } = useQuery({ 2 queryKey: ['categories'], 3 queryFn: fetchCategories, 4 placeholderData: DEFAULT_CATEGORIES, // Datos hasta que lleguen los reales 5});
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}
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}
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.