import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { PaginationState } from '@tanstack/react-table';
export function useTablePagination() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const page = Number(searchParams.get('page')) || 1;
const pageSize = Number(searchParams.get('per_page')) || 10;
const pagination: PaginationState = {
pageIndex: page - 1,
pageSize,
};
function setPagination(updater: any) {
const nextState = typeof updater === 'function' ? updater(pagination) : updater;
const params = new URLSearchParams(searchParams.toString());
params.set('page', String(nextState.pageIndex + 1));
params.set('per_page', String(nextState.pageSize));
startTransition(() => {
router.push(`${pathname}?${params.toString()}`);
});
}
return { pagination, setPagination, isPending };
}Am avut de făcut recent un panel de admin la un proiect cu vreo 50.000 de tranzacții. Clasic, clientul a vrut direct shadcn/ui pentru că arată bine din fabrică și se livrează repede. Doar că implementarea lor standard de DataTable se bazează pe filtrare și paginare client-side. La volumul ăsta de date, dacă încerci să încarci totul deodată în browser, thread-ul de JS moare în chinuri și userul dă refresh nervos.
Soluția corectă este să muți tot greul pe server (bază de date + API) și să folosești URL-ul ca sursă unică de adevăr pentru starea tabelului.
De ce URL Search Params și nu React State?
Când folosești state local (useState) pentru paginare sau sortare, pierzi starea instantaneu la refresh. Dacă vrei să trimiți un link unui coleg cu pagina 3, filtrat după tranzacțiile eșuate, el va deschide pagina 1, curată.
Folosind URL-ul (ex: /transactions?page=3&sort=amount.desc&status=failed), rezolvi trei probleme dintr-un foc:
- Link-urile pot fi salvate în bookmark-uri sau partajate direct pe Slack.
- Butonul de Back din browser funcționează natural fără să strice filtrele.
- Server Component-ul din Next.js știe exact ce să ceară din baza de date la primul render, eliminând layout shift-urile.
Există însă un trade-off major. Fiecare filtrare sau schimbare de pagină înseamnă un request la server (network roundtrip). Pentru filtrarea după text, dacă trimiți request la fiecare tastă apăsată, îți vei bombarda baza de date inutil. Aici devine obligatoriu un debounce de măcar 300-400ms înainte de a scrie în URL.
Cum legăm TanStack Table de URL
În loc să lăsăm TanStack Table să-și gestioneze singur starea internă, trebuie să îi pasăm pageCount calculat pe server și să controlăm manual evenimentele de schimbare a paginii folosind router-ul din Next.js.
Folosesc useTransition din React ca să nu blochez interfața în timp ce Next.js încarcă noile date de pe server. Astfel, utilizatorul vede instant că se întâmplă ceva (un loader discret), chiar dacă baza de date are o mică latență.
Capcane de care m-am lovit în producție
Prima mare problemă: resetarea paginii. Dacă ești la pagina 10 și decizi să schimbi un filtru (de exemplu, selectezi doar tranzacțiile finalizate), trebuie neapărat să forțezi resetarea paginii la 1 în query params. Altfel, noul tău set de date filtrat s-ar putea să aibă doar 2 pagini în total, iar tu vei încerca să ceri pagina 10, primind un tabel gol și un user foarte derutat.
A doua problemă ține de performanța bazelor de date. Paginarea cu OFFSET și LIMIT în SQL (cum face majoritatea lumii) devine extrem de lentă la offset-uri mari (de exemplu, dacă sari la pagina 5000). Dacă ai seturi masive de date, recomand paginarea bazată pe cursor (keyset pagination), deși e mult mai greu de implementat cu un UI clasic de paginare.
Până la urmă, server-side-ul câștigă detașat când ai date dinamice și multe, dar vine cu costul unui boilerplate destul de stufos comparat cu varianta client-side.
Voi cum gestionați tabelele mari în Next.js? Mergeți pe URL params sau preferați să faceți fetch din client cu React Query și paginare clasică?