useInfiniteQuery
useInfiniteQuery: Paginación Infinita
Section titled “useInfiniteQuery: Paginación Infinita”useInfiniteQuery
es perfecto para implementar scroll infinito, load more buttons, y cualquier tipo de paginación donde cargas datos incrementalmente. Maneja automáticamente múltiples “páginas” de datos.
🎯 Concepto Básico
Section titled “🎯 Concepto Básico”A diferencia de useQuery
que maneja una sola respuesta, useInfiniteQuery
maneja múltiples “páginas” de datos que se van acumulando:
const { data, // { pages: [page1, page2, ...], pageParams: [param1, param2, ...] } fetchNextPage, // Función para cargar la siguiente página hasNextPage, // Si hay más páginas disponibles isFetchingNextPage, // Si está cargando la siguiente página // ... otros estados similares a useQuery} = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam), getNextPageParam: (lastPage, allPages) => { // Lógica para determinar el siguiente parámetro de página return lastPage.hasMore ? allPages.length : undefined; }});
🚀 Implementación Básica
Section titled “🚀 Implementación Básica”Scroll Infinito Simple
Section titled “Scroll Infinito Simple”import { useInfiniteQuery } from '@tanstack/react-query';import { useEffect } from 'react';
// Función que simula una API paginadaconst fetchPosts = async ({ pageParam = 0 }) => { const response = await fetch( `https://jsonplaceholder.typicode.com/posts?_page=${pageParam + 1}&_limit=10` );
const posts = await response.json();
return { posts, nextPage: posts.length === 10 ? pageParam + 1 : undefined, hasMore: posts.length === 10 };};
function InfinitePostsList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error } = useInfiniteQuery({ queryKey: ['posts', 'infinite'], queryFn: fetchPosts, getNextPageParam: (lastPage) => lastPage.nextPage, initialPageParam: 0 // Parámetro inicial de página });
// Auto-scroll infinito cuando llegamos al final useEffect(() => { const handleScroll = () => { if ( window.innerHeight + document.documentElement.scrollTop === document.documentElement.offsetHeight ) { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } } };
window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) return <div>Cargando posts...</div>; if (isError) return <div>Error: {error.message}</div>;
return ( <div style={{ padding: '20px' }}> <h2>📜 Posts con Scroll Infinito</h2>
<div> {data.pages.map((page, pageIndex) => ( <div key={pageIndex}> {page.posts.map(post => ( <div key={post.id} style={{ margin: '15px 0', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', backgroundColor: '#f9f9f9' }} > <h3>{post.title}</h3> <p>{post.body}</p> <small style={{ color: '#666' }}>Post ID: {post.id}</small> </div> ))} </div> ))} </div>
{/* Loading indicator */} {isFetchingNextPage && ( <div style={{ textAlign: 'center', padding: '20px' }}> 🔄 Cargando más posts... </div> )}
{/* Load more button (alternativa al auto-scroll) */} {hasNextPage && !isFetchingNextPage && ( <div style={{ textAlign: 'center', padding: '20px' }}> <button onClick={() => fetchNextPage()} style={{ padding: '10px 20px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }} > 📖 Cargar más posts </button> </div> )}
{/* Fin de los datos */} {!hasNextPage && ( <div style={{ textAlign: 'center', padding: '20px', color: '#666' }}> 🎉 ¡Has visto todos los posts! </div> )} </div> );}
export default InfinitePostsList;
🔧 Hook Personalizado para Scroll Infinito
Section titled “🔧 Hook Personalizado para Scroll Infinito”Crea un hook reutilizable para scroll infinito:
import { useEffect } from 'react';
export function useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage, threshold = 100}) { useEffect(() => { const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
// Activar cuando estemos cerca del final (threshold pixels) if (scrollTop + clientHeight >= scrollHeight - threshold) { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } } };
window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, [fetchNextPage, hasNextPage, isFetchingNextPage, threshold]);}
// Uso del hookfunction PostsList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } = useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, getNextPageParam: (lastPage) => lastPage.nextPage, initialPageParam: 0 });
// Usar el hook personalizado useInfiniteScroll({ fetchNextPage, hasNextPage, isFetchingNextPage, threshold: 200 // Activar cuando estemos a 200px del final });
// ... resto del componente}
📊 Diferentes Tipos de Paginación
Section titled “📊 Diferentes Tipos de Paginación”1. Paginación Basada en Cursor
Section titled “1. Paginación Basada en Cursor”const fetchPostsByCursor = async ({ pageParam = null }) => { const url = pageParam ? `/api/posts?cursor=${pageParam}&limit=10` : '/api/posts?limit=10';
const response = await fetch(url); const data = await response.json();
return { posts: data.posts, nextCursor: data.nextCursor // null si no hay más páginas };};
function CursorBasedPosts() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ queryKey: ['posts', 'cursor'], queryFn: fetchPostsByCursor, getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: null });
// ... resto del componente}
2. Paginación con Offset/Limit
Section titled “2. Paginación con Offset/Limit”const fetchPostsWithOffset = async ({ pageParam = 0 }) => { const limit = 10; const offset = pageParam * limit;
const response = await fetch(`/api/posts?offset=${offset}&limit=${limit}`); const data = await response.json();
return { posts: data.posts, hasMore: data.posts.length === limit, nextOffset: data.posts.length === limit ? pageParam + 1 : undefined };};
function OffsetBasedPosts() { const query = useInfiniteQuery({ queryKey: ['posts', 'offset'], queryFn: fetchPostsWithOffset, getNextPageParam: (lastPage) => lastPage.nextOffset, initialPageParam: 0 });
// ... resto del componente}
3. Paginación con Timestamps
Section titled “3. Paginación con Timestamps”const fetchPostsByTimestamp = async ({ pageParam = new Date().toISOString() }) => { const response = await fetch(`/api/posts?before=${pageParam}&limit=10`); const data = await response.json();
return { posts: data.posts, oldestTimestamp: data.posts.length > 0 ? data.posts[data.posts.length - 1].createdAt : null };};
function TimeBasedPosts() { const query = useInfiniteQuery({ queryKey: ['posts', 'timeline'], queryFn: fetchPostsByTimestamp, getNextPageParam: (lastPage) => lastPage.oldestTimestamp, initialPageParam: new Date().toISOString() });
// ... resto del componente}
🎨 Componente Avanzado con Virtualizacion
Section titled “🎨 Componente Avanzado con Virtualizacion”Para listas muy largas, combina useInfiniteQuery
con virtualización:
import { FixedSizeList as List } from 'react-window';import InfiniteLoader from 'react-window-infinite-loader';
function VirtualizedInfiniteList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ queryKey: ['posts', 'virtualized'], queryFn: fetchPosts, getNextPageParam: (lastPage) => lastPage.nextPage, initialPageParam: 0 });
if (isLoading) return <div>Cargando...</div>;
// Aplanar todas las páginas en un solo array const allPosts = data.pages.flatMap(page => page.posts);
// Determinar cuántos items tenemos y si hay más const itemCount = hasNextPage ? allPosts.length + 1 : allPosts.length;
const isItemLoaded = (index) => !!allPosts[index];
const loadMoreItems = isFetchingNextPage ? () => {} : fetchNextPage;
const PostItem = ({ index, style }) => { const post = allPosts[index];
if (!post) { return ( <div style={style}> <div style={{ padding: '20px', textAlign: 'center' }}> Cargando... </div> </div> ); }
return ( <div style={{ ...style, padding: '10px' }}> <div style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '15px', backgroundColor: '#f9f9f9' }}> <h3>{post.title}</h3> <p>{post.body}</p> </div> </div> ); };
return ( <div style={{ height: '600px', width: '100%' }}> <h2>📜 Lista Virtualizada Infinita</h2>
<InfiniteLoader isItemLoaded={isItemLoaded} itemCount={itemCount} loadMoreItems={loadMoreItems} > {({ onItemsRendered, ref }) => ( <List ref={ref} height={500} itemCount={itemCount} itemSize={200} onItemsRendered={onItemsRendered} width="100%" > {PostItem} </List> )} </InfiniteLoader> </div> );}
🔄 Bidirectional Infinite Query
Section titled “🔄 Bidirectional Infinite Query”Para casos donde necesitas cargar tanto hacia adelante como hacia atrás:
function BidirectionalInfiniteList() { const { data, fetchNextPage, fetchPreviousPage, hasNextPage, hasPreviousPage, isFetchingNextPage, isFetchingPreviousPage } = useInfiniteQuery({ queryKey: ['posts', 'bidirectional'], queryFn: async ({ pageParam = { cursor: null, direction: 'next' } }) => { const { cursor, direction } = pageParam;
const url = cursor ? `/api/posts?cursor=${cursor}&direction=${direction}&limit=10` : '/api/posts?limit=10';
const response = await fetch(url); const data = await response.json();
return { posts: data.posts, nextCursor: data.nextCursor, previousCursor: data.previousCursor }; }, getNextPageParam: (lastPage) => lastPage.nextCursor ? { cursor: lastPage.nextCursor, direction: 'next' } : undefined, getPreviousPageParam: (firstPage) => firstPage.previousCursor ? { cursor: firstPage.previousCursor, direction: 'previous' } : undefined, initialPageParam: { cursor: null, direction: 'next' } });
return ( <div> {/* Load Previous Button */} {hasPreviousPage && ( <div style={{ textAlign: 'center', padding: '20px' }}> <button onClick={() => fetchPreviousPage()} disabled={isFetchingPreviousPage} > {isFetchingPreviousPage ? '⏳ Cargando anteriores...' : '⬆️ Cargar anteriores'} </button> </div> )}
{/* Posts */} <div> {data.pages.map((page, pageIndex) => ( <div key={pageIndex}> {page.posts.map(post => ( <div key={post.id}> <h3>{post.title}</h3> <p>{post.body}</p> </div> ))} </div> ))} </div>
{/* Load Next Button */} {hasNextPage && ( <div style={{ textAlign: 'center', padding: '20px' }}> <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? '⏳ Cargando siguientes...' : '⬇️ Cargar siguientes'} </button> </div> )} </div> );}
🎛️ Opciones Avanzadas
Section titled “🎛️ Opciones Avanzadas”Configuración Completa
Section titled “Configuración Completa”const query = useInfiniteQuery({ queryKey: ['posts', filters], queryFn: fetchPosts,
// Parámetros de página initialPageParam: 0, getNextPageParam: (lastPage, allPages) => lastPage.nextPage, getPreviousPageParam: (firstPage, allPages) => firstPage.previousPage,
// Configuración de cache staleTime: 5 * 60 * 1000, cacheTime: 10 * 60 * 1000,
// Límites maxPages: 10, // Máximo número de páginas a mantener en cache
// Comportamiento refetchOnWindowFocus: false, retry: 3,
// Transformación de datos select: (data) => ({ pages: data.pages.map(page => ({ ...page, posts: page.posts.filter(post => post.published) })), pageParams: data.pageParams }),
// Callbacks onSuccess: (data) => { const totalPosts = data.pages.reduce((acc, page) => acc + page.posts.length, 0); console.log(`Cargados ${totalPosts} posts en ${data.pages.length} páginas`); }});
Manejo de Estados Complejos
Section titled “Manejo de Estados Complejos”function ComplexInfiniteList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error, isRefetching, refetch } = useInfiniteQuery({ queryKey: ['posts'], queryFn: fetchPosts, getNextPageParam: (lastPage) => lastPage.nextPage, initialPageParam: 0 });
// Estados derivados const totalPosts = data?.pages.reduce((acc, page) => acc + page.posts.length, 0) || 0; const totalPages = data?.pages.length || 0; const isEmpty = totalPosts === 0; const isLoadingFirst = isLoading; const isRefreshingAll = isRefetching && !isFetchingNextPage;
if (isLoadingFirst) { return ( <div style={{ textAlign: 'center', padding: '50px' }}> <div>🔄 Cargando posts...</div> </div> ); }
if (isError) { return ( <div style={{ textAlign: 'center', padding: '50px' }}> <div style={{ color: 'red' }}>❌ Error: {error.message}</div> <button onClick={() => refetch()}>Reintentar</button> </div> ); }
if (isEmpty) { return ( <div style={{ textAlign: 'center', padding: '50px' }}> <div>📭 No hay posts disponibles</div> <button onClick={() => refetch()}>Actualizar</button> </div> ); }
return ( <div> {/* Header con estadísticas */} <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px', marginBottom: '20px' }}> <div> 📊 {totalPosts} posts en {totalPages} páginas {isRefreshingAll && ' 🔄'} </div> <button onClick={() => refetch()} disabled={isRefreshingAll}> {isRefreshingAll ? '🔄 Actualizando...' : '🔄 Actualizar'} </button> </div>
{/* Lista de posts */} <div> {data.pages.map((page, pageIndex) => ( <div key={pageIndex}> {page.posts.map((post, postIndex) => ( <div key={post.id} style={{ margin: '15px 0', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', backgroundColor: '#f9f9f9' }} > <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '10px' }}> <strong>Post #{post.id}</strong> <small>Página {pageIndex + 1}, Item {postIndex + 1}</small> </div> <h3>{post.title}</h3> <p>{post.body}</p> </div> ))} </div> ))} </div>
{/* Controles de carga */} <div style={{ textAlign: 'center', padding: '20px' }}> {isFetchingNextPage && ( <div style={{ marginBottom: '20px' }}> 🔄 Cargando más posts... </div> )}
{hasNextPage && !isFetchingNextPage && ( <button onClick={() => fetchNextPage()} style={{ padding: '12px 24px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '16px' }} > 📖 Cargar más posts </button> )}
{!hasNextPage && ( <div style={{ color: '#666' }}> 🎉 ¡Has visto todos los posts! </div> )} </div> </div> );}
✅ Mejores Prácticas para useInfiniteQuery
Section titled “✅ Mejores Prácticas para useInfiniteQuery”- Define getNextPageParam correctamente: Es crucial para determinar si hay más páginas
- Usa initialPageParam: Para establecer el parámetro inicial de página
- Implementa loading states: Para páginas iniciales y siguientes
- Considera la virtualización: Para listas muy largas
- Maneja el final de datos: Muestra cuando no hay más contenido
- Optimiza el threshold: Para scroll infinito no muy agresivo
- Usa maxPages: Para limitar memoria en listas infinitas muy largas
- Implementa pull-to-refresh: Para mejor UX en móviles
- Maneja errores específicos: Para fallos en páginas individuales
- Considera bidirectional loading: Para feeds de tiempo real
¡Excelente! Ya sabes cómo implementar scroll infinito con useInfiniteQuery
. Ahora vamos a explorar conceptos avanzados de cache e invalidación.
Próximo paso: Cache y Invalidation