Paginación con SWR
Receta
Usa useSWRInfinite para gestionar datos paginados y de scroll infinito. Define una función getKey que devuelva la URL correcta para cada índice de página, y llama a setSize para cargar más páginas.
"use client";
import useSWRInfinite from "swr/infinite";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
const getKey = (pageIndex: number, previousPageData: any[] | null) => {
if (previousPageData && previousPageData.length === 0) return null; // se alcanzó el final
return `/api/posts?page=${pageIndex}&limit=10`;
};
function PostFeed() {
const { data, size, setSize, isLoading, isValidating } = useSWRInfinite(getKey, fetcher);
const posts = data ? data.flat() : [];
const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === "undefined");
const isEmpty = data?.[0]?.length === 0;
const isReachingEnd = isEmpty || (data && data[data.length - 1]?.length < 10);
return (
<div>
{posts.map((post) => (
<article key={post.id}>{post.title}</article>
))}
<button
disabled={isLoadingMore || isReachingEnd}
onClick={() => setSize(size + 1)}
>
{isLoadingMore ? "Cargando..." : isReachingEnd ? "No hay más publicaciones" : "Cargar más"}
</button>
</div>
);
}Ejemplo funcional
"use client";
import useSWRInfinite from "swr/infinite";
interface Product {
id: string;
name: string;
price: number;
}
interface PageResponse {
items: Product[];
nextCursor: string | null;
}
const fetcher = (url: string): Promise<PageResponse> =>
fetch(url).then((r) => r.json());
export default function ProductCatalog() {
const { data, size, setSize, isLoading } = useSWRInfinite<PageResponse>(
(pageIndex, previousPageData) => {
// Primera página — no se necesita cursor
if (pageIndex === 0) return "/api/products?limit=20";
// No hay más páginas
if (!previousPageData?.nextCursor) return null;
// Paginación basada en cursor
return `/api/products?cursor=${previousPageData.nextCursor}&limit=20`;
},
fetcher
);
const products = data?.flatMap((page) => page.items) ?? [];
const hasMore = data?.[data.length - 1]?.nextCursor !== null;
if (isLoading) return <div>Cargando productos...</div>;
return (
<div>
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<div key={product.id} className="border p-4 rounded">
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
{hasMore && (
<button onClick={() => setSize(size + 1)}>Cargar más</button>
)}
</div>
);
}Profundización
Cómo funciona
useSWRInfinitegestiona un array de páginas, donde cada página es el resultado de una obtención separada.- La función
getKey(pageIndex, previousPageData)se invoca para cada índice de página empezando desde 0. - Devolver
nulldesdegetKeyindica a SWR que deje de obtener más páginas. dataes un array de arrays (o un array de objetos de página), una entrada por cada página cargada.setSize(n)establece cuántas páginas deben cargarse. SWR obtiene cualquier página que aún no esté en caché.- Cada página tiene su propia entrada en caché, por lo que las páginas individuales pueden revalidarse de forma independiente.
Variaciones
Paginación basada en offset:
const getKey = (pageIndex: number) => `/api/items?offset=${pageIndex * 20}&limit=20`;Con Intersection Observer para scroll infinito:
import { useRef, useCallback } from "react";
function InfiniteList() {
const { data, size, setSize } = useSWRInfinite(getKey, fetcher);
const observer = useRef<IntersectionObserver>();
const lastElementRef = useCallback(
(node: HTMLElement | null) => {
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setSize((s) => s + 1);
}
});
if (node) observer.current.observe(node);
},
[setSize]
);
const items = data?.flat() ?? [];
return (
<ul>
{items.map((item, i) => (
<li key={item.id} ref={i === items.length - 1 ? lastElementRef : undefined}>
{item.name}
</li>
))}
</ul>
);
}Obtención paralela de páginas:
const { data } = useSWRInfinite(getKey, fetcher, {
parallel: true, // Obtener todas las páginas en paralelo durante la revalidación
});Notas de TypeScript
- Los genéricos de
useSWRInfinite<Data, Error>tipan cada página en el array de datos. dataesData[] | undefined— un array de páginas.- La función
getKeyrecibe(pageIndex: number, previousPageData: Data | null).
const { data } = useSWRInfinite<Product[]>(getKey, fetcher);
// data: Product[][] | undefined
const allProducts = data?.flat() ?? [];
// allProducts: Product[]Errores comunes
dataes un array de páginas, no una lista plana. Casi siempre necesitas.flat()o.flatMap()para obtener un único array.- Establecer
revalidateFirstPage: falsepuede evitar reobtenciones innecesarias de la página 0 al cargar páginas adicionales, pero significa que los datos de la página 0 podrían quedar obsoletos. setSizedispara obtenciones para cualquier página que aún no esté en caché. EstablecersetSize(1)no descarta las páginas 2+ de la caché; solo controla lo que se renderiza.- La paginación basada en cursor requiere los datos de la página anterior para calcular la siguiente clave, por lo que las páginas siempre se cargan de forma secuencial (sin obtención paralela en la carga inicial).
- Mutar datos paginados es complejo. Prefiere revalidar toda la lista después de una mutación en lugar de actualizar manualmente páginas individuales.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| useSWRInfinite | Integrado en SWR, caché a nivel de página | Lógica de getKey compleja, forma de datos array-de-arrays |
| useSWR manual por página | Modelo mental más simple | Sin "cargar más" ni seguimiento de páginas integrados |
| React Query useInfiniteQuery | API similar, getNextPageParam integrado | Ecosistema de biblioteca diferente |
| Server Components con searchParams | Sin JS en el cliente, apto para SEO | Recarga completa de página en cada cambio de página |
FAQs
¿Por qué useSWRInfinite devuelve un array de arrays en lugar de una lista plana?
Cada página se obtiene como una solicitud separada y se cachea de forma independiente. data es un array de resultados de página. Necesitas .flat() o .flatMap() para producir una única lista plana para renderizar.
¿Cómo controla la paginación la función getKey?
getKey(pageIndex, previousPageData) se invoca para cada página empezando en el índice 0. Devuelve la URL de esa página. Devuelve null para indicar que no hay más páginas que obtener.
¿Cuál es la diferencia entre la paginación basada en offset y la basada en cursor con useSWRInfinite?
- Basada en offset:
getKeycalcula la URL a partir depageIndex(p. ej.,?offset=${pageIndex * 20}). - Basada en cursor:
getKeylee un cursor depreviousPageData(p. ej.,?cursor=${previousPageData.nextCursor}). - La basada en cursor requiere carga secuencial; la basada en offset puede admitir obtención paralela.
¿Cómo implemento scroll infinito con useSWRInfinite?
Usa un IntersectionObserver en el último elemento renderizado. Cuando se vuelve visible, llama a setSize(s => s + 1) para cargar la página siguiente.
¿Qué hace la opción parallel en useSWRInfinite?
const { data } = useSWRInfinite(getKey, fetcher, {
parallel: true,
});Obtiene todas las páginas cargadas en paralelo durante la revalidación. Nota: esto solo funciona para paginación basada en offset, ya que la basada en cursor requiere los datos de la página anterior.
Trampa: ¿setSize(1) descarta los datos en caché de otras páginas?
No. setSize(1) solo controla cuántas páginas se renderizan, no lo que hay en la caché. Las páginas 2+ permanecen en caché. Esto puede generar confusión si esperas que reducir el tamaño borre los datos antiguos.
¿Cómo debo gestionar mutaciones con datos paginados?
Prefiere revalidar toda la lista después de una mutación en lugar de actualizar manualmente páginas individuales. Las actualizaciones manuales de caché a nivel de página son complejas y propensas a errores con useSWRInfinite.
Trampa: ¿Qué ocurre si establezco revalidateFirstPage: false?
SWR omite reobtener la página 0 cuando se cargan páginas adicionales. Esto reduce las solicitudes de red, pero significa que los datos de la página 0 pueden quedar obsoletos sin que el usuario se dé cuenta.
¿Cómo tipar useSWRInfinite con genéricos de TypeScript?
const { data } = useSWRInfinite<Product[]>(getKey, fetcher);
// data: Product[][] | undefined
const allProducts = data?.flat() ?? [];
// allProducts: Product[]El genérico tipa cada página en el array de datos, por lo que data se convierte en Data[].
¿Cómo detecto cuándo no hay más páginas que cargar?
Comprueba los datos de la última página. Si tiene menos elementos que el límite de la página o está vacía, has llegado al final:
const isEmpty = data?.[0]?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE);