Paginación con TanStack Query
Paginación con TanStack Query
Section titled “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.
Tipos de Paginación
Section titled “Tipos de Paginación”1. Paginación Tradicional
Section titled “1. Paginación Tradicional”- Botones “Anterior” y “Siguiente”
- Navegación directa a páginas específicas
- Ideal para tablas y listas estructuradas
2. Scroll Infinito
Section titled “2. Scroll Infinito”- Carga automática al llegar al final
- Experiencia fluida sin interrupciones
- Perfecto para feeds y contenido continuo
API de Ejemplo
Section titled “API de Ejemplo”Primero, definamos nuestra API que soporta paginación:
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;}
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(); },};
Paginación Tradicional
Section titled “Paginación Tradicional”Hook Personalizado
Section titled “Hook Personalizado”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 });};
Componente de Lista con Paginación
Section titled “Componente de Lista con Paginación”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> );};
Componente de Paginación
Section titled “Componente de Paginación”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> );};
Scroll Infinito con useInfiniteQuery
Section titled “Scroll Infinito con useInfiniteQuery”Hook para Scroll Infinito
Section titled “Hook para Scroll Infinito”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, });};
Componente con Scroll Infinito
Section titled “Componente con Scroll Infinito”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> );};
Optimizaciones Avanzadas
Section titled “Optimizaciones Avanzadas”1. Prefetching de Páginas
Section titled “1. Prefetching de Páginas”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;};
2. Cache Selectivo para Infinite Queries
Section titled “2. Cache Selectivo para Infinite Queries”// Mantener solo las últimas 3 páginas en memoriaconst 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, });};
3. Paginación con Estado en URL
Section titled “3. Paginación con Estado en URL”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 };};
Mejores Prácticas
Section titled “Mejores Prácticas”1. Gestión de Estados de Carga
Section titled “1. Gestión de Estados de Carga”- Usar
placeholderData: keepPreviousData
para transiciones suaves - Mostrar indicadores de carga específicos para cada acción
2. Optimización de Performance
Section titled “2. Optimización de Performance”- Implementar prefetching para páginas siguientes
- Limitar páginas en memoria para infinite queries
- Usar debounce para búsquedas
3. Experiencia de Usuario
Section titled “3. Experiencia de Usuario”- Mantener el estado de scroll al navegar
- Proporcionar feedback visual durante cargas
- Implementar fallbacks para cargas manuales
4. Gestión de URLs
Section titled “4. Gestión de URLs”- 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.