Skip to content

Paginación con TanStack Query

La paginación es uno de los casos de uso más comunes en aplicaciones web. TanStack Query ofrece herramientas poderosas para manejar tanto paginación tradicional como scroll infinito de manera eficiente.

  • Botones “Anterior” y “Siguiente”
  • Navegación directa a páginas específicas
  • Ideal para tablas y listas estructuradas
  • Carga automática al llegar al final
  • Experiencia fluida sin interrupciones
  • Perfecto para feeds y contenido continuo

Primero, definamos nuestra API que soporta paginación:

types/pagination.ts
export interface PaginationParams {
page: number;
limit: number;
search?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
currentPage: number;
totalPages: number;
totalItems: number;
hasNext: boolean;
hasPrevious: boolean;
limit: number;
};
}
export interface User {
id: number;
name: string;
email: string;
avatar: string;
createdAt: string;
}
api/users.ts
export const usersApi = {
getUsers: async (params: PaginationParams): Promise<PaginatedResponse<User>> => {
const searchParams = new URLSearchParams({
page: params.page.toString(),
limit: params.limit.toString(),
...(params.search && { search: params.search }),
...(params.sortBy && { sortBy: params.sortBy }),
...(params.sortOrder && { sortOrder: params.sortOrder }),
});
const response = await fetch(`/api/users?${searchParams}`);
if (!response.ok) {
throw new Error('Error al obtener usuarios');
}
return response.json();
},
// Para scroll infinito - API que retorna cursor
getUsersInfinite: async ({
pageParam = 0
}: {
pageParam?: number
}): Promise<{
users: User[];
nextCursor: number | null;
hasMore: boolean;
}> => {
const response = await fetch(`/api/users/infinite?cursor=${pageParam}&limit=20`);
if (!response.ok) {
throw new Error('Error al obtener usuarios');
}
return response.json();
},
};
hooks/useUsers.ts
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { usersApi } from '../api/users';
import type { PaginationParams } from '../types/pagination';
export const useUsers = (params: PaginationParams) => {
return useQuery({
queryKey: ['users', params],
queryFn: () => usersApi.getUsers(params),
placeholderData: keepPreviousData, // Mantiene datos anteriores durante carga
staleTime: 5 * 60 * 1000, // 5 minutos
});
};
components/UsersList.tsx
import React, { useState } from 'react';
import { useUsers } from '../hooks/useUsers';
import type { PaginationParams } from '../types/pagination';
export const UsersList: React.FC = () => {
const [params, setParams] = useState<PaginationParams>({
page: 1,
limit: 10,
search: '',
sortBy: 'name',
sortOrder: 'asc',
});
const { data, isLoading, isPlaceholderData, error } = useUsers(params);
const handlePageChange = (newPage: number) => {
setParams(prev => ({ ...prev, page: newPage }));
};
const handleSearch = (search: string) => {
setParams(prev => ({ ...prev, search, page: 1 }));
};
const handleSort = (sortBy: string) => {
setParams(prev => ({
...prev,
sortBy,
sortOrder: prev.sortBy === sortBy && prev.sortOrder === 'asc' ? 'desc' : 'asc',
page: 1,
}));
};
if (isLoading && !isPlaceholderData) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-red-800">Error: {error.message}</p>
</div>
);
}
const users = data?.data ?? [];
const pagination = data?.pagination;
return (
<div className="max-w-6xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-4">Lista de Usuarios</h1>
{/* Barra de búsqueda */}
<div className="flex space-x-4 mb-4">
<input
type="text"
placeholder="Buscar usuarios..."
value={params.search}
onChange={(e) => handleSearch(e.target.value)}
className="flex-1 p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
/>
<select
value={params.limit}
onChange={(e) => setParams(prev => ({
...prev,
limit: Number(e.target.value),
page: 1
}))}
className="p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
>
<option value={5}>5 por página</option>
<option value={10}>10 por página</option>
<option value={20}>20 por página</option>
<option value={50}>50 por página</option>
</select>
</div>
</div>
{/* Tabla de usuarios */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('name')}
>
Nombre
{params.sortBy === 'name' && (
<span className="ml-1">
{params.sortOrder === 'asc' ? '' : ''}
</span>
)}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('email')}
>
Email
{params.sortBy === 'email' && (
<span className="ml-1">
{params.sortOrder === 'asc' ? '' : ''}
</span>
)}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
onClick={() => handleSort('createdAt')}
>
Fecha de Registro
{params.sortBy === 'createdAt' && (
<span className="ml-1">
{params.sortOrder === 'asc' ? '' : ''}
</span>
)}
</th>
</tr>
</thead>
<tbody className={`bg-white divide-y divide-gray-200 ${
isPlaceholderData ? 'opacity-50' : ''
}`}>
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<img
className="h-10 w-10 rounded-full"
src={user.avatar}
alt={user.name}
/>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.name}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500">No se encontraron usuarios</p>
</div>
)}
</div>
{/* Componente de paginación */}
{pagination && (
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.totalItems}
onPageChange={handlePageChange}
isLoading={isPlaceholderData}
/>
)}
</div>
);
};
components/Pagination.tsx
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
onPageChange: (page: number) => void;
isLoading?: boolean;
}
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
totalItems,
onPageChange,
isLoading = false,
}) => {
const getVisiblePages = () => {
const delta = 2;
const range = [];
const rangeWithDots = [];
for (
let i = Math.max(2, currentPage - delta);
i <= Math.min(totalPages - 1, currentPage + delta);
i++
) {
range.push(i);
}
if (currentPage - delta > 2) {
rangeWithDots.push(1, '...');
} else {
rangeWithDots.push(1);
}
rangeWithDots.push(...range);
if (currentPage + delta < totalPages - 1) {
rangeWithDots.push('...', totalPages);
} else {
rangeWithDots.push(totalPages);
}
return rangeWithDots;
};
const visiblePages = getVisiblePages();
return (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6 mt-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1 || isLoading}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Anterior
</button>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Siguiente
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Mostrando{' '}
<span className="font-medium">
{(currentPage - 1) * 10 + 1}
</span>{' '}
a{' '}
<span className="font-medium">
{Math.min(currentPage * 10, totalItems)}
</span>{' '}
de{' '}
<span className="font-medium">{totalItems}</span>{' '}
resultados
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1 || isLoading}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
</button>
{visiblePages.map((page, index) => (
<React.Fragment key={index}>
{page === '...' ? (
<span className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
...
</span>
) : (
<button
onClick={() => onPageChange(page as number)}
disabled={isLoading}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === page
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
} disabled:opacity-50`}
>
{page}
</button>
)}
</React.Fragment>
))}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
</button>
</nav>
</div>
</div>
</div>
);
};
hooks/useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { usersApi } from '../api/users';
export const useInfiniteUsers = () => {
return useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: usersApi.getUsersInfinite,
initialPageParam: 0,
getNextPageParam: (lastPage) => {
return lastPage.hasMore ? lastPage.nextCursor : undefined;
},
staleTime: 5 * 60 * 1000,
});
};
components/InfiniteUsersList.tsx
import React, { useEffect } from 'react';
import { useInfiniteUsers } from '../hooks/useInfiniteUsers';
import { useInView } from 'react-intersection-observer';
export const InfiniteUsersList: React.FC = () => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
error,
} = useInfiniteUsers();
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '100px', // Cargar cuando esté a 100px del final
});
// Cargar más cuando el elemento está en vista
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-red-800">Error: {error.message}</p>
</div>
);
}
const allUsers = data?.pages.flatMap(page => page.users) ?? [];
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Usuarios (Scroll Infinito)</h1>
<div className="space-y-4">
{allUsers.map((user, index) => (
<div
key={`${user.id}-${index}`}
className="bg-white p-6 rounded-lg shadow hover:shadow-md transition-shadow"
>
<div className="flex items-center space-x-4">
<img
className="h-12 w-12 rounded-full"
src={user.avatar}
alt={user.name}
/>
<div>
<h3 className="text-lg font-semibold">{user.name}</h3>
<p className="text-gray-600">{user.email}</p>
<p className="text-sm text-gray-400">
Registrado: {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
))}
</div>
{/* Elemento trigger para cargar más */}
<div ref={ref} className="flex justify-center py-8">
{isFetchingNextPage ? (
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
) : hasNextPage ? (
<p className="text-gray-500">Cargando más...</p>
) : allUsers.length > 0 ? (
<p className="text-gray-500">No hay más usuarios</p>
) : null}
</div>
{/* Botón manual como fallback */}
{hasNextPage && !inView && (
<div className="text-center mt-6">
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isFetchingNextPage ? 'Cargando...' : 'Cargar más'}
</button>
</div>
)}
</div>
);
};
hooks/useUsersWithPrefetch.ts
import { useQuery, useQueryClient } from '@tanstack/react-query';
export const useUsersWithPrefetch = (params: PaginationParams) => {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['users', params],
queryFn: () => usersApi.getUsers(params),
placeholderData: keepPreviousData,
});
// Prefetch siguiente página
useEffect(() => {
if (query.data?.pagination.hasNext) {
const nextParams = { ...params, page: params.page + 1 };
queryClient.prefetchQuery({
queryKey: ['users', nextParams],
queryFn: () => usersApi.getUsers(nextParams),
staleTime: 5 * 60 * 1000,
});
}
}, [query.data, params, queryClient]);
return query;
};
// Mantener solo las últimas 3 páginas en memoria
const useInfiniteUsersOptimized = () => {
return useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: usersApi.getUsersInfinite,
initialPageParam: 0,
getNextPageParam: (lastPage) => {
return lastPage.hasMore ? lastPage.nextCursor : undefined;
},
maxPages: 3, // Limitar páginas en memoria
staleTime: 5 * 60 * 1000,
});
};
hooks/useUrlPagination.ts
import { useSearchParams } from 'react-router-dom';
export const useUrlPagination = () => {
const [searchParams, setSearchParams] = useSearchParams();
const params: PaginationParams = {
page: Number(searchParams.get('page') || '1'),
limit: Number(searchParams.get('limit') || '10'),
search: searchParams.get('search') || '',
sortBy: searchParams.get('sortBy') || 'name',
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'asc',
};
const updateParams = (updates: Partial<PaginationParams>) => {
const newParams = { ...params, ...updates };
const urlParams = new URLSearchParams();
Object.entries(newParams).forEach(([key, value]) => {
if (value) {
urlParams.set(key, value.toString());
}
});
setSearchParams(urlParams);
};
return { params, updateParams };
};
  • Usar placeholderData: keepPreviousData para transiciones suaves
  • Mostrar indicadores de carga específicos para cada acción
  • Implementar prefetching para páginas siguientes
  • Limitar páginas en memoria para infinite queries
  • Usar debounce para búsquedas
  • Mantener el estado de scroll al navegar
  • Proporcionar feedback visual durante cargas
  • Implementar fallbacks para cargas manuales
  • Sincronizar parámetros de paginación con la URL
  • Permitir navegación directa a páginas específicas
  • Mantener filtros y ordenamiento en la URL

La paginación eficiente mejora significativamente la performance y experiencia de usuario en aplicaciones con grandes volúmenes de datos.