¿Qué hace Desplazamiento Infinito?
Reglas
- Paginación basada en cursor: usa el ID o marca de tiempo del último elemento como cursor — no usar offset, que se rompe con inserciones/eliminaciones
- IntersectionObserver: conéctalo a un elemento centinela en la parte inferior de la lista — activa la obtención de datos cuando sea visible
- Virtualización:
@tanstack/react-virtual para listas con 1,000+ elementos — renderiza solo las filas visibles en el DOM
- Esqueleto de carga: muestra elementos de marcador de posición en la parte inferior mientras se obtiene la siguiente página — no un spinner
- Botón "Cargar más": proporciona como alternativa para usuarios que prefieren control manual o tienen conexiones lentas
- Restauración de la posición de desplazamiento: guarda la posición de desplazamiento antes de la navegación, restaura al regresar — usa
sessionStorage o el estado del enrutador
- Estado vacío: muestra un mensaje cuando la lista tiene cero elementos — no una página en blanco
- Fin de la lista: muestra "Has llegado al final" cuando
hasNextPage sea falso — detén el observador
Patrones
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";
function InfiniteList() {
const sentinelRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["items"],
queryFn: ({ pageParam }) => fetchItems({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
initialPageParam: undefined as string | undefined,
});
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage(); },
{ rootMargin: "200px" }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div>
{allItems.map((item) => <ItemCard key={item.id} item={item} />)}
{isFetchingNextPage && <LoadingSkeleton count={3} />}
{!hasNextPage && allItems.length > 0 && <p>Has llegado al final</p>}
{allItems.length === 0 && !isFetchingNextPage && <EmptyState />}
<div ref={sentinelRef} />
</div>
);
}
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.key} style={{
position: "absolute", top: 0, left: 0, width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}>
<ItemCard item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Evitar
- Paginación basada en offset — las inserciones/eliminaciones desplazan todos los offsets, causando elementos duplicados o faltantes
- Cargar todos los elementos y luego filtrar en el cliente — obten solo la página actual del servidor
- Renderizar más de 10,000 nodos DOM — usa
@tanstack/react-virtual para renderizar solo las filas visibles
- IntersectionObserver sin
rootMargin — agrega un margen de 100-200px para precargar antes de que el usuario llegue al fondo
- Falta de indicador de fin de lista — los usuarios necesitan saber que no hay más contenido para cargar