useQuery
useQuery: El Hook Fundamental
Section titled “useQuery: El Hook Fundamental”useQuery
es el hook más importante de TanStack Query. Se usa para obtener datos del servidor y gestionarlos automáticamente en el cache. En esta sección exploraremos todas sus capacidades.
🎯 Anatomía de useQuery
Section titled “🎯 Anatomía de useQuery”const { data, // Los datos obtenidos isLoading, // Primera carga (no hay datos en cache) isFetching, // Cualquier petición en chapter isError, // Si hay un error error, // El objeto error isSuccess, // Si la petición fue exitosa refetch, // Función para recargar manualmente // ... más propiedades} = useQuery({ queryKey, // Identificador único queryFn, // Función que obtiene los datos // ... opciones});
📊 Estados del Hook
Section titled “📊 Estados del Hook”Estados Principales
Section titled “Estados Principales”function UserProfile({ userId }) { const { data, isLoading, isFetching, isError, error } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
// isLoading: true solo en la primera carga (sin datos en cache) if (isLoading) return <div>🔄 Cargando usuario...</div>;
// isError: true si hay algún error if (isError) return <div>❌ Error: {error.message}</div>;
// data: undefined hasta que se cargan los datos if (!data) return <div>📭 Usuario no encontrado</div>;
return ( <div> <h2>{data.name} {isFetching && '🔄'}</h2> <p>{data.email}</p> {/* isFetching: true durante cualquier petición (incluso refetch) */} </div> );}
Estados Avanzados
Section titled “Estados Avanzados”function AdvancedUserProfile({ userId }) { const { data, isLoading, // Primera carga isFetching, // Cualquier fetch isStale, // Datos obsoletos isPlaceholderData, // Si se están mostrando datos placeholder isPreviousData, // Si se muestran datos de una query anterior isIdle, // Query deshabilitada status, // 'idle' | 'loading' | 'error' | 'success' fetchStatus, // 'idle' | 'fetching' | 'paused' failureCount, // Número de reintentos fallidos refetch, remove // Función para eliminar query del cache } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), enabled: !!userId });
// UI granular basada en estados if (status === 'idle') return <div>⏳ Query deshabilitada</div>; if (isLoading) return <div>🔄 Cargando por primera vez...</div>; if (isError) return <div>❌ Error después de {failureCount} intentos</div>;
return ( <div> <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> <h2>{data.name}</h2> {isStale && <span style={{ color: 'orange' }}>📡 Datos obsoletos</span>} {isFetching && <span>🔄 Actualizando...</span>} {isPreviousData && <span style={{ color: 'blue' }}>👻 Datos anteriores</span>} </div>
<p>{data.email}</p>
<div> <button onClick={() => refetch()}>Actualizar</button> <button onClick={() => remove()}>Limpiar Cache</button> </div> </div> );}
⚙️ Opciones de Configuración
Section titled “⚙️ Opciones de Configuración”Cache y Tiempos
Section titled “Cache y Tiempos”const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts,
// Control de cache staleTime: 5 * 60 * 1000, // 5 min - tiempo hasta marcar como obsoleto cacheTime: 10 * 60 * 1000, // 10 min - tiempo en cache después de unmount
// Comportamiento de refetch refetchOnWindowFocus: true, // Refetch al enfocar ventana refetchOnReconnect: true, // Refetch al reconectar internet refetchOnMount: true, // Refetch al montar componente refetchInterval: 30000, // Refetch cada 30 segundos refetchIntervalInBackground: false, // Solo refetch si ventana está enfocada});
Ejecución Condicional
Section titled “Ejecución Condicional”function UserPosts({ userId, isVisible }) { const { data: posts } = useQuery({ queryKey: ['posts', userId], queryFn: () => fetchUserPosts(userId),
// Solo ejecutar si userId existe y el componente es visible enabled: !!userId && isVisible,
// También puedes usar función enabled: () => { return userId && userId > 0 && isVisible; } });
return ( <div> {posts?.map(post => <div key={post.id}>{post.title}</div>)} </div> );}
Retry y Manejo de Errores
Section titled “Retry y Manejo de Errores”const { data } = useQuery({ queryKey: ['api-data'], queryFn: fetchApiData,
// Configuración de reintentos retry: 3, // Número de reintentos retry: (failureCount, error) => { // Lógica personalizada de retry if (error.status === 404) return false; // No reintentar 404 if (error.status >= 500) return failureCount < 3; // Solo errores del servidor return false; },
// Delay entre reintentos (backoff exponencial) retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
// Callbacks de error onError: (error) => { console.error('Query failed:', error); // Enviar a servicio de logging // Mostrar notificación }});
Transformación de Datos
Section titled “Transformación de Datos”const { data: publishedPosts } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts,
// Transformar datos antes de devolverlos select: (data) => { return data .filter(post => post.published) // Solo publicados .sort((a, b) => new Date(b.date) - new Date(a.date)) // Más recientes primero .map(post => ({ // Transformar estructura ...post, excerpt: post.content.substring(0, 100) + '...', formattedDate: new Date(post.date).toLocaleDateString() })); }});
🔄 Queries Dependientes
Section titled “🔄 Queries Dependientes”Cuando una query depende del resultado de otra:
function UserPostsAndComments({ userId }) { // Primera query: obtener usuario const { data: user, isLoading: userLoading } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), enabled: !!userId });
// Segunda query: obtener posts del usuario (depende de la primera) const { data: posts, isLoading: postsLoading } = useQuery({ queryKey: ['posts', user?.id], queryFn: () => fetchUserPosts(user.id), enabled: !!user?.id // Solo ejecutar si tenemos el usuario });
// Tercera query: obtener comentarios del primer post const { data: comments } = useQuery({ queryKey: ['comments', posts?.[0]?.id], queryFn: () => fetchPostComments(posts[0].id), enabled: !!posts?.[0]?.id });
if (userLoading) return <div>Cargando usuario...</div>; if (postsLoading) return <div>Cargando posts...</div>;
return ( <div> <h2>{user.name}</h2> <div> {posts?.map(post => ( <div key={post.id}> <h3>{post.title}</h3> {post.id === posts[0]?.id && comments && ( <div> <h4>Comentarios:</h4> {comments.map(comment => ( <p key={comment.id}>{comment.body}</p> ))} </div> )} </div> ))} </div> </div> );}
📝 Datos Iniciales y Placeholder
Section titled “📝 Datos Iniciales y Placeholder”Initial Data
Section titled “Initial Data”const { data: user } = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId),
// Datos iniciales (se consideran fresh) initialData: { id: userId, name: 'Usuario Cargando...', email: '' },
// O función que devuelve datos iniciales initialData: () => { // Intentar obtener datos del cache de otra query return queryClient.getQueryData(['users']) ?.find(user => user.id === userId); }});
Placeholder Data
Section titled “Placeholder Data”const { data: posts, isPlaceholderData } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts,
// Datos placeholder (se consideran stale) placeholderData: [ { id: 1, title: 'Cargando...', body: 'Este post se está cargando...' }, { id: 2, title: 'Cargando...', body: 'Este post se está cargando...' } ],
// O función para generar placeholder placeholderData: () => { return Array.from({ length: 5 }, (_, i) => ({ id: i + 1, title: `Post ${i + 1} cargando...`, body: 'Contenido cargando...' })); }});
// UI que muestra si son datos placeholderreturn ( <div> {isPlaceholderData && <div>📡 Cargando datos reales...</div>} {posts?.map(post => ( <div key={post.id} style={{ opacity: isPlaceholderData ? 0.5 : 1 }}> <h3>{post.title}</h3> <p>{post.body}</p> </div> ))} </div>);
🎛️ Callbacks y Side Effects
Section titled “🎛️ Callbacks y Side Effects”const { data } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts,
// Callbacks durante el ciclo de vida onSuccess: (data) => { console.log('✅ Posts cargados exitosamente:', data.length);
// Guardar en localStorage localStorage.setItem('lastPostsCount', data.length);
// Enviar analytics analytics.track('posts_loaded', { count: data.length }); },
onError: (error) => { console.error('❌ Error cargando posts:', error);
// Mostrar notificación de error toast.error(`Error: ${error.message}`);
// Enviar error a servicio de logging errorLogger.log(error); },
onSettled: (data, error) => { // Se ejecuta siempre, con éxito o error console.log('🏁 Query completada');
// Limpiar loading states globales setGlobalLoading(false); }});
🧪 Ejemplo Avanzado: Búsqueda con Debounce
Section titled “🧪 Ejemplo Avanzado: Búsqueda con Debounce”import { useState, useMemo } from 'react';import { useQuery } from '@tanstack/react-query';import { useDebounce } from 'use-debounce'; // npm install use-debounce
function SearchPosts() { const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm] = useDebounce(searchTerm, 300);
const { data: posts = [], isLoading, isFetching, error, refetch } = useQuery({ queryKey: ['posts', 'search', debouncedSearchTerm], queryFn: async () => { if (!debouncedSearchTerm) return [];
const response = await fetch( `https://jsonplaceholder.typicode.com/posts?title_like=${debouncedSearchTerm}` );
if (!response.ok) { throw new Error('Error en búsqueda'); }
return response.json(); },
// Solo buscar si hay término de búsqueda enabled: !!debouncedSearchTerm && debouncedSearchTerm.length >= 2,
// Configuración para búsqueda staleTime: 5 * 60 * 1000, // 5 minutos keepPreviousData: true, // Mantener datos anteriores mientras carga
// Callbacks onSuccess: (data) => { console.log(`Encontrados ${data.length} posts para "${debouncedSearchTerm}"`); } });
// Memoizar estadísticas para evitar recálculos const stats = useMemo(() => ({ totalResults: posts.length, isSearching: searchTerm !== debouncedSearchTerm, hasResults: posts.length > 0 }), [posts.length, searchTerm, debouncedSearchTerm]);
return ( <div style={{ padding: '20px' }}> <div style={{ marginBottom: '20px' }}> <input type="text" placeholder="Buscar posts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} style={{ width: '100%', padding: '10px', fontSize: '16px', border: '1px solid #ccc', borderRadius: '4px' }} />
<div style={{ marginTop: '10px', fontSize: '14px', color: '#666' }}> {stats.isSearching && '⌨️ Escribiendo...'} {isFetching && '🔍 Buscando...'} {!isFetching && debouncedSearchTerm && ( <span> 📊 {stats.totalResults} resultados para "{debouncedSearchTerm}" </span> )} </div> </div>
{error && ( <div style={{ color: 'red', margin: '10px 0' }}> ❌ Error: {error.message} <button onClick={() => refetch()} style={{ marginLeft: '10px' }}> Reintentar </button> </div> )}
{isLoading && ( <div style={{ textAlign: 'center', padding: '20px' }}> 🔄 Buscando posts... </div> )}
{!isLoading && stats.hasResults && ( <div> {posts.map(post => ( <div key={post.id} style={{ margin: '10px 0', padding: '15px', border: '1px solid #eee', borderRadius: '8px', backgroundColor: '#f9f9f9' }} > <h3 style={{ margin: '0 0 10px 0' }}>{post.title}</h3> <p style={{ margin: 0, color: '#666' }}>{post.body}</p> </div> ))} </div> )}
{!isLoading && debouncedSearchTerm && !stats.hasResults && ( <div style={{ textAlign: 'center', padding: '20px', color: '#666' }}> 📭 No se encontraron posts para "{debouncedSearchTerm}" </div> )} </div> );}
export default SearchPosts;
✅ Mejores Prácticas para useQuery
Section titled “✅ Mejores Prácticas para useQuery”- Usa arrays para queryKey:
['posts', id]
es mejor que'posts-' + id
- Manten queryKeys consistentes: Define tus claves en un lugar centralizado
- Usa enabled para queries condicionales: Evita peticiones innecesarias
- Transforma datos con select: Para filtrar o modificar respuestas
- Configura staleTime apropiadamente: Basado en qué tan frecuentemente cambian tus datos
- Maneja todos los estados:
isLoading
,error
,data
- Usa keepPreviousData para listas: Para mejor UX en paginación/filtros
- Implementa retry inteligente: No reintentar errores 4xx
- Aprovecha el cache: Queries con mismas keys comparten datos
- Usa callbacks para side effects:
onSuccess
,onError
,onSettled
¡Ahora dominas useQuery
! Es momento de aprender sobre las mutaciones para modificar datos en el servidor.
Próximo paso: useMutation