Skip to content

useInfiniteQuery

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.

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;
}
});
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
// Función que simula una API paginada
const 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:

hooks/useInfiniteScroll.js
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 hook
function 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
}
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
}
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
}
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>
);
}

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>
);
}
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`);
}
});
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”
  1. Define getNextPageParam correctamente: Es crucial para determinar si hay más páginas
  2. Usa initialPageParam: Para establecer el parámetro inicial de página
  3. Implementa loading states: Para páginas iniciales y siguientes
  4. Considera la virtualización: Para listas muy largas
  5. Maneja el final de datos: Muestra cuando no hay más contenido
  6. Optimiza el threshold: Para scroll infinito no muy agresivo
  7. Usa maxPages: Para limitar memoria en listas infinitas muy largas
  8. Implementa pull-to-refresh: Para mejor UX en móviles
  9. Maneja errores específicos: Para fallos en páginas individuales
  10. 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