import { useTransition } from "react"
import { useRouter, usePathname, useSearchParams } from "next/navigation"
import { useReactTable, getCoreRowModel, PaginationState, SortingState } from "@tanstack/react-table"
export function useServerTableState({ pageCount }: { pageCount: number }) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [isPending, startTransition] = useTransition()
const page = Number(searchParams.get("page")) || 1
const perPage = Number(searchParams.get("perPage")) || 10
const sort = searchParams.get("sort") || "createdAt.desc"
const [sortId, sortDesc] = sort.split(".")
const pagination: PaginationState = { pageIndex: page - 1, pageSize: perPage }
const sorting: SortingState = [{ id: sortId, desc: sortDesc === "desc" }]
function updateUrl(params: Record<string, string | number | null>) {
const nextParams = new URLSearchParams(searchParams.toString())
Object.entries(params).forEach(([key, val]) => {
if (val === null || val === "") nextParams.delete(key)
else nextParams.set(key, String(val))
})
startTransition(() => {
router.push(`${pathname}?${nextParams.toString()}`, { scroll: false })
})
}
return {
pagination,
sorting,
isPending,
onPaginationChange: (updater: any) => {
const next = typeof updater === "function" ? updater(pagination) : updater
updateUrl({ page: next.pageIndex + 1, perPage: next.pageSize })
},
onSortingChange: (updater: any) => {
const next = typeof updater === "function" ? updater(sorting) : updater
const first = next[0]
updateUrl({ sort: first ? `${first.id}.${first.desc ? "desc" : "asc"}` : null, page: 1 })
}
}
}Să arunci un DataTable din shadcn peste 300 de rânduri mockuite pe client e treabă de zece minute. Dar la un proiect recent de audit cu vreo 65.000 de înregistrări financiare, client-side pagination a crăpat instant browserul contabililor, trăgând după el un JSON de 14MB la fiecare refresh.
Soluția firească e mutarea logicii pe server, însă TanStack Table a fost gândit nativ cu mentalitatea de client state. Dacă nu ești atent cum legi starea tabelei de App Router în Next.js, te trezești cu re-render-uri recursive, flicker la tastare și un URL plin de parametri stricați.
URL-ul ca unică sursă de adevăr
Cea mai mare greșeală pe care am făcut-o inițial a fost să țin pagination și sorting în useState local în interiorul componentei de tabel, încercând să sincronizez acel state cu router.push. Se băteau cap în cap. Făceai back în browser, state-ul local rămânea decuplat, iar tabela afișa pagina 3 în timp ce API-ul primea pagina 1.
Regula e simplă: URL-ul dictează tot. Server Component-ul citește searchParams, le validează strict cu Zod (pentru că userii sau bot-urile scriu idioțenii în URL), trimite parametrii către baza de date sau ORM, și pasează datele brute către Client Component-ul tabelei.
În client, inițializezi TanStack Table activând explicit flag-urile manuale: manualPagination: true, manualSorting: true și manualFiltering: true. Asta îi spune bibliotecii: „Stai potolită, nu filtra tu în memorie, doar raportează-mi când dă utilizatorul click pe un header”.
Capcana latenței și useTransition
Când muți totul pe server, fiecare click pe pagină sau sortare declanșează un Server Action sau un router transition. Dacă faci un simplu router.push('?page=2'), UI-ul devine inert timp de 150-300ms până când baza de date răspunde.
Aici intervine useTransition. Când împachetezi actualizarea URL-ului în startTransition, React marchează update-ul ca non-urgent. Poți afișa un spinner subtil direct peste tabelă sau poți lăsa rândurile vechi cu opacitate redusă (isPending), oferind utilizatorului senzația de responsivitate fără layout shift-uri violente.
Pentru input-urile de căutare (filtrare după text), nu trimite request la fiecare tastă. Un debounce clasic de 300-400ms combinat cu useOptimistic sau un input controlat separat previne bombardarea bazei de date cu zeci de query-uri SQL inutile per secundă.
Trade-off-uri reale
Arhitectura asta are un cost clar. Merge brici când vrei link-uri share-abile (trimiți pe Slack colegului exact ?page=4&sort=desc&status=failed și vede fix același lucru) și când ai volume serioase de date. Noi am tăiat transferul de date de la 14MB la sub 40KB per tranziție de pagină.
Partea nasoală? Devine un chin dacă ai 15 filtre compuse (de exemplu: multi-select pe categorii, range de date, threshold-uri numerice). URL-ul devine o ciorbă greu de parsat dacă nu folosești o librărie precum nuqs, iar fiecare mică schimbare de stare trece printr-un round-trip de server, ceea ce elimină senzația aia de instant UI pe care o ai pe client.
Voi cum gestionați filtrele complexe în tabele pe App Router: serializați obiecte JSON direct în searchParams sau preferați parametri clasici pentru fiecare câmp?